fix: bind sidebar selection to filtered content

Root cause: single global 'cur' node ID was shared across all
sections. Switching between 'Клиенты'/'Проекты'/etc did not
change the rendered content because renderToday/renderInbox/
loadSection all loaded the same flat root-node list and
switchTab('ov') re-rendered selN(cur) regardless of which
section was active.

Fix: split selection into two distinct states:
  sel = {kind:'section', section:'today'|'inbox'|'clients'|...}
    or  sel = {kind:'node',     nodeId:'<uuid>'}

Each section renders its own content (title, quick-actions,
filtered items, empty state). Real nodes show their own
dashboard. Tab dispatch checks sel.kind first.

Sidebar separated into:
  'НАВИГАЦИЯ' — virtual sections (today/inbox/7 categories)
  'ДЕЛА'       — real user nodes from API

Russian type labels everywhere (TL map). Section metadata
(SEC_META) provides per-section empty states and action types.

Known limitation: section content currently shows all root
nodes (backend has no section/group column yet). When section
assignment is added to the data model, filtering will wire
up without frontend changes — renderSectionList already
receives the section id.
This commit is contained in:
2026-05-30 21:56:07 +08:00
parent 168625671a
commit 14ff1a25b9
2 changed files with 488 additions and 477 deletions
+3 -259
View File
@@ -1,259 +1,3 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Верстак</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#1a1a2e;color:#e0e0e0;height:100vh;display:flex;overflow:hidden}
#sb{width:280px;background:#16213e;border-right:1px solid #0f3460;display:flex;flex-direction:column;overflow:hidden;flex-shrink:0}
#sb-hdr{padding:16px;font-size:18px;font-weight:700;color:#e94560;border-bottom:1px solid #0f3460;display:flex;justify-content:space-between;align-items:center}
#sb-hdr button{background:#0f3460;color:#e0e0e0;border:1px solid #1a4080;border-radius:4px;padding:4px 10px;cursor:pointer;font-size:12px}
#sb-hdr button:hover{background:#1a4080}
#tree{flex:1;overflow-y:auto;padding:8px 0}
#tree ul{list-style:none}
#tree li{user-select:none}
.ti{padding:6px 16px;cursor:pointer;display:flex;align-items:center;gap:8px;font-size:13px;border-radius:4px;margin:1px 6px}
.ti:hover{background:#0f3460}
.ti.sel{background:#1a4080;color:#fff}
.ti .ic{width:16px;text-align:center;color:#e94560;font-size:14px}
.ti .ar{width:12px;font-size:10px;color:#555}
.tc{margin-left:18px;border-left:1px dashed #0f3460}
#main{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0}
#mh{padding:14px 24px;border-bottom:1px solid #0f3460;display:flex;justify-content:space-between;align-items:center;gap:12px}
#mh h1{font-size:18px;font-weight:600;white-space:overflow:hidden;text-overflow:ellipsis}
#mh .acts{display:flex;gap:6px;flex-shrink:0}
#mh button{background:#0f3460;color:#e0e0e0;border:1px solid #1a4080;border-radius:4px;padding:6px 12px;cursor:pointer;font-size:12px}
#mh button:hover{background:#1a4080}
#mh button.p{background:#e94560;border-color:#e94560;color:#fff}
#mh button.p:hover{background:#c73652}
#tabs{display:flex;padding:0 24px;border-bottom:1px solid #0f3460;flex-shrink:0}
#tabs .t{padding:10px 18px;cursor:pointer;font-size:13px;color:#888;border-bottom:2px solid transparent;white-space:nowrap}
#tabs .t:hover{color:#e0e0e0}
#tabs .t.a{color:#e94560;border-bottom-color:#e94560}
#srch{padding:8px 24px}
#srch input{width:100%;padding:8px 12px;background:#0f0f23;color:#e0e0e0;border:1px solid #0f3460;border-radius:6px;font-size:13px;outline:none}
#srch input:focus{border-color:#e94560}
#sr-res{display:none;position:absolute;top:100px;left:50%;transform:translateX(-50%);width:400px;max-width:90vw;background:#16213e;border:1px solid #0f3460;border-radius:8px;max-height:350px;overflow-y:auto;z-index:60}
#sr-res .sri{padding:10px 14px;cursor:pointer;border-bottom:1px solid #0f3460;font-size:13px}
#sr-res .sri:hover{background:#0f3460}
#sr-res .sri .srt{font-size:10px;color:#e94560;text-transform:uppercase}
#cnt{flex:1;overflow-y:auto;padding:24px}
.bc{font-size:12px;color:#666;margin-bottom:12px}
.empty{display:flex;align-items:center;justify-content:center;height:100%;color:#555;font-size:15px}
.cg{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px}
.card{background:#16213e;border:1px solid #0f3460;border-radius:8px;padding:14px;cursor:pointer;transition:border-color .15s}
.card:hover{border-color:#e94560}
.card .ct{font-weight:600;margin-bottom:4px;font-size:14px}
.card .cy{font-size:11px;color:#888;text-transform:uppercase}
.card .cm{font-size:11px;color:#555;margin-top:6px}
#ed{display:none;position:fixed;inset:0;background:#1a1a2e;z-index:90;flex-direction:column}
#ed-hdr{padding:12px 24px;border-bottom:1px solid #0f3460;display:flex;justify-content:space-between;align-items:center}
#ed-hdr span{font-size:16px;font-weight:600}
#ed button{background:#0f3460;color:#e0e0e0;border:1px solid #1a4080;border-radius:4px;padding:6px 14px;cursor:pointer;font-size:12px}
#ed button.p{background:#e94560;border-color:#e94560}
#ed-ta{flex:1;background:#0f0f23;color:#e0e0e0;border:none;padding:24px;font-family:"Fira Code",Consolas,monospace;font-size:14px;line-height:1.7;resize:none;outline:none}
#ed-ftr{padding:10px 24px;border-top:1px solid #0f3460;display:flex;gap:8px}
.mo{display:none;position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:100;align-items:center;justify-content:center}
.mo.on{display:flex}
.md{background:#16213e;border:1px solid #0f3460;border-radius:12px;padding:24px;width:420px;max-width:90vw}
.md h3{margin-bottom:16px;font-size:16px}
.md label{display:block;font-size:12px;color:#888;margin-bottom:4px;margin-top:12px}
.md input,.md select{width:100%;padding:8px 12px;background:#0f0f23;color:#e0e0e0;border:1px solid #1a4080;border-radius:4px;font-size:14px;outline:none}
.md input:focus{border-color:#e94560}
.md .ma{display:flex;gap:8px;justify-content:flex-end;margin-top:20px}
.md button{padding:8px 16px;border-radius:4px;border:1px solid #1a4080;background:#0f3460;color:#e0e0e0;cursor:pointer}
.md button.p{background:#e94560;border-color:#e94560;color:#fff}
</style>
</head>
<body>
<div id="sb">
<div id="sb-hdr"><span>⚒ ВЕРСТАК</span><button onclick="showCM()">+</button></div>
<div id="tree"><div class="empty" style="padding:30px 14px;font-size:13px">Пусто. Создайте первое дело.</div></div>
</div>
<div id="main">
<div id="mh"><h1 id="pt">Верстак</h1><div class="acts">
<button onclick="showCNM()">+ Заметка</button>
<button onclick="showCM()">+ Дело</button>
<button onclick="document.getElementById('fi').click()">+ Файл</button>
<input type="file" id="fi" style="display:none" onchange="handleFA(event)">
</div></div>
<div id="tabs"><div class="t a" onclick="switchTab(this,'ov')">Обзор</div><div class="t" onclick="switchTab(this,'nt')">Заметки</div><div class="t" onclick="switchTab(this,'fl')">Файлы</div></div>
<div id="srch"><input id="si" placeholder="Поиск по делам и заметкам..." autocomplete="off" oninput="handleSR(this.value)"></div>
<div id="sr-res"></div>
<div id="cnt"><div class="empty">Выберите дело или создайте новое</div></div>
</div>
<div id="ed">
<div id="ed-hdr"><span id="et">Редактор</span><div><button onclick="closeED()"></button> <button class="p" onclick="saveNT()">💾 Сохранить</button></div></div>
<textarea id="ed-ta" placeholder="Пишите в Markdown..."></textarea>
<div id="ed-ftr"></div>
</div>
<div class="mo" id="cm">
<div class="md"><h3>Новое дело</h3>
<label>Тип</label><select id="ct"><option value="case">◇ Дело</option><option value="folder">▸ Папка</option><option value="space">◎ Пространство</option><option value="recipe">◈ Рецепт</option></select>
<label>Название</label><input id="cn" placeholder="Название..." autofocus>
<div class="ma"><button onclick="closeCM()">Отмена</button><button class="p" onclick="submitCM()">Создать</button></div></div>
</div>
<div class="mo" id="cnm">
<div class="md"><h3>Новая заметка</h3>
<label>Название</label><input id="cnn" placeholder="Название заметки..." autofocus>
<div class="ma"><button onclick="closeCNM()">Отмена</button><button class="p" onclick="submitCNM()">Создать</button></div></div>
</div>
<script>
const API='';
let cur='',editId='',tab='ov';
async function api(p,opt){
const r=await fetch(API+p,{headers:{'Content-Type':'application/json'},...opt});
if(!r.ok)throw new Error(r.status);
return r.json();
}
// tree
async function loadTree(){
try{
const items=await api('/api/nodes');
const t=document.getElementById('tree');
if(!items.length){t.innerHTML='<div class="empty" style="padding:30px 14px;font-size:13px">Пусто. Создайте первое дело.</div>';return}
t.innerHTML=renderL(items,'');
}catch(e){document.getElementById('tree').innerHTML='<div class="empty" style="padding:30px 14px">Ошибка загрузки</div>'}
}
function renderL(items,pid){
let h='<ul'+(pid?' class="tc"':'')+'>';
for(const n of items){
h+='<li><div class="ti'+(cur===n.id?' sel':'')+'" data-id="'+n.id+'" onclick="selN(\''+n.id+'\')">';
h+='<span class="ar">'+('▸')+'</span><span class="ic">'+nic(n.type)+'</span>';
h+='<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(n.title)+'</span></div></li>';
}
h+='</ul>';return h;
}
function nic(t){const i={case:'◇',folder:'▸',space:'◎',note:'📝',document:'📄',file:'📎',recipe:'◈',action:'⚡',secret:'🔒',worklog:'⏱',link:'🔗'};return i[t]||'◇'}
function esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML}
async function selN(id){
cur=id;
document.querySelectorAll('.ti').forEach(e=>e.classList.toggle('sel',e.dataset.id===id));
try{
const d=await api('/api/nodes/'+id);
document.getElementById('pt').textContent=d.node.title;
renderOV(d);
}catch(e){document.getElementById('cnt').innerHTML='<div class="empty">Ошибка</div>'}
}
function renderOV(d){
const ch=d.children||[],fl=d.files||[];
let h='<div class="bc">'+(d.node.type||'Дело')+'</div>';
if(ch.length||fl.length){
h+='<div class="cg">';
for(const c of ch){
h+='<div class="card" onclick="selN(\''+c.id+'\')"><div class="ct">'+esc(c.title)+'</div><div class="cy">'+c.type+'</div></div>';
}
for(const f of fl){
h+='<div class="card"><div class="ct">📎 '+esc(f.filename)+'</div><div class="cy">'+(f.mime||'file')+'</div><div class="cm">'+fsz(f.size)+'</div></div>';
}
h+='</div>';
} else {
h+='<div class="empty" style="margin-top:40px">Пусто. Добавьте заметку или файл.</div>';
}
document.getElementById('cnt').innerHTML=h;
}
function fsz(b){if(b<1024)return b+' B';if(b<1048576)return(b/1024).toFixed(1)+' KB';return(b/1048576).toFixed(1)+' MB'}
function switchTab(el,t){
document.querySelectorAll('#tabs .t').forEach(e=>e.classList.remove('a'));
el.classList.add('a');tab=t;
if(t==='ov'&&cur)selN(cur);else if(t==='nt')loadNT();else if(t==='fl')loadFL();
}
async function loadNT(){
if(!cur){document.getElementById('cnt').innerHTML='<div class="empty">Выберите делó</div>';return}
const d=await api('/api/nodes/'+cur);
const ns=(d.children||[]).filter(c=>c.type==='note');
if(!ns.length){document.getElementById('cnt').innerHTML='<div class="empty">Нет заметок. Создайте первую.</div>';return}
let h='<div class="cg">';
for(const n of ns)h+='<div class="card" onclick="openNT(\''+n.id+'\')"><div class="ct">📝 '+esc(n.title)+'</div><div class="cy">заметка</div></div>';
h+='</div>';document.getElementById('cnt').innerHTML=h;
}
async function loadFL(){
if(!cur){document.getElementById('cnt').innerHTML='<div class="empty">Выберите дело</div>';return}
const d=await api('/api/nodes/'+cur);
const fl=d.files||[];
if(!fl.length){document.getElementById('cnt').innerHTML='<div class="empty">Нет файлов</div>';return}
let h='<div class="cg">';
for(const f of fl)h+='<div class="card"><div class="ct">📎 '+esc(f.filename)+'</div><div class="cy">'+(f.mime||'file')+'</div><div class="cm">'+fsz(f.size)+'</div></div>';
h+='</div>';document.getElementById('cnt').innerHTML=h;
}
// editor
async function openNT(id){
editId=id;
try{const d=await api('/api/notes/'+id);document.getElementById('et').textContent='Заметка';document.getElementById('ed-ta').value=d.content||'';document.getElementById('ed').style.display='flex'}
catch(e){alert('Ошибка: '+e.message)}
}
function closeED(){document.getElementById('ed').style.display='none';editId=''}
async function saveNT(){
if(!editId)return;
try{await api('/api/notes/'+editId,{method:'PUT',body:JSON.stringify({content:document.getElementById('ed-ta').value})});closeED();if(cur)selN(cur)}
catch(e){alert('Ошибка: '+e.message)}
}
// modals
function showCM(){document.getElementById('cm').classList.add('on');document.getElementById('cn').focus()}
function closeCM(){document.getElementById('cm').classList.remove('on');document.getElementById('cn').value=''}
async function submitCM(){
const type=document.getElementById('ct').value,title=document.getElementById('cn').value.trim();
if(!title)return;
try{await api('/api/nodes',{method:'POST',body:JSON.stringify({parent_id:cur,type,title})});closeCM();loadTree();if(cur)selN(cur)}
catch(e){alert('Ошибка: '+e.message)}
}
function showCNM(){document.getElementById('cnm').classList.add('on');document.getElementById('cnn').focus()}
function closeCNM(){document.getElementById('cnm').classList.remove('on');document.getElementById('cnn').value=''}
async function submitCNM(){
const title=document.getElementById('cnn').value.trim();
if(!title)return;
try{const n=await api('/api/notes/'+(cur||''),{method:'POST',body:JSON.stringify({parent_id:cur,title})});closeCNM();loadTree();selN(n.id);switchTab(document.querySelectorAll('#tabs .t')[1],'nt')}
catch(e){alert('Ошибка: '+e.message)}
}
function handleFA(ev){const f=ev.target.files[0];if(!f)return;alert('MVP: загрузка файлов будет в следующей версии. Файл: '+f.name);ev.target.value=''}
// search
let sT=null;
async function handleSR(q){
clearTimeout(sT);
const b=document.getElementById('sr-res');
if(!q||q.length<2){b.style.display='none';return}
sT=setTimeout(async()=>{
try{
// For MVP, fetch all roots and filter locally
const items=await api('/api/nodes');
const hits=items.filter(n=>n.title.toLowerCase().includes(q.toLowerCase()));
if(!hits.length){b.style.display='none';return}
let h='';for(const r of hits){h+='<div class="sri" onclick="selN(\''+r.id+'\');b.style.display=\'none\'"><div class="srt">'+r.type+'</div><div>'+esc(r.title)+'</div></div>'}
b.innerHTML=h;b.style.display='block';
}catch(e){b.style.display='none'}
},200);
}
// keyboard shortcuts
document.addEventListener('keydown',e=>{
if(e.key==='Escape'){closeED();closeCM();closeCNM();document.getElementById('sr-res').style.display='none'}
if(e.key==='n'&&(e.ctrlKey||e.metaKey)){e.preventDefault();showCM()}
});
loadTree();
</script>
</body>
</html>
// This file is unused — the GUI now serves HTML from internal/gui/index.html.go
// via an inline template. Kept to avoid the embed directory dependency.
// Safe to delete in a cleanup pass.