step 6 redesign: dashboard, sidebar sections, add menu, editor UX

Per 10-point UX spec:
- Contrast boosted (text #e4e4ef instead of #ccc)
- Dashboard view: badges, quick-actions, grouped sub-items/notes/files
- Notes tab filters by type=note, Files tab shows only files
- Tab placeholders: Actions, Worklog, Activity
- Single + Add button with dropdown menu (case/note/file/action/worklog)
- Sidebar navigation: Today, Inbox, Clients, Projects, Recipes, Docs, Archive
- Russian type labels (no raw CASE/note)
- Note editor: breadcrumb, title, readable-width textarea (720px)
- Wails-ready SPA: inline JS, fetch to /api/*, no external deps
This commit is contained in:
mirivlad 2026-05-30 21:04:18 +08:00
parent 39271fc28f
commit 168625671a
2 changed files with 373 additions and 130 deletions

View File

@ -14,9 +14,9 @@
| 1 | Git init + Skeleton | ✅ выполнен | | 1 | Git init + Skeleton | ✅ выполнен |
| 2 | Init + SQLite + First Migration | ✅ выполнен | | 2 | Init + SQLite + First Migration | ✅ выполнен |
| 3 | Nodes Repository + CRUD + CLI Node | ✅ выполнен | | 3 | Nodes Repository + CRUD + CLI Node | ✅ выполнен |
| 4 | Vault Files: Trash + File Service + CLI File | ⬜ не начат | | 4 | Vault Files: Trash + File Service + CLI File | ✅ выполнен |
| 5 | Markdown Notes: Create/Read/Save + CLI Note | ⬜ не начат | | 5 | Markdown Notes: Create/Read/Save + CLI Note | ✅ выполнен |
| 6 | Wails GUI MVP: Sidebar + Main Panel | ⬜ не начат | | 6 | Wails GUI MVP: Sidebar + Main Panel | ✅ выполнен |
| 7 | Actions: Run URL/File/Command + GUI Tab | ⬜ не начат | | 7 | Actions: Run URL/File/Command + GUI Tab | ⬜ не начат |
| 8 | Worklog: Entries + Report + GUI Tab | ⬜ не начат | | 8 | Worklog: Entries + Report + GUI Tab | ⬜ не начат |
| 9 | FTS5 Search: Rebuild Index + GUI Search Bar | ⬜ не начат | | 9 | FTS5 Search: Rebuild Index + GUI Search Bar | ⬜ не начат |

View File

@ -1,6 +1,8 @@
package gui package gui
// indexHTML is the GUI frontend served inline. // indexHTML — SPA-фронтенд Верстака (тёмная тема, левое дерево, вкладки).
// Весь UI в одном файле: CSS + HTML + JS. Wails-совместимость: структура
// готова к упаковке (нет external JS/CSS, fetch к /api/* через origin).
const indexHTML = `<!DOCTYPE html> const indexHTML = `<!DOCTYPE html>
<html lang="ru"> <html lang="ru">
<head> <head>
@ -8,172 +10,413 @@ const indexHTML = `<!DOCTYPE html>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Верстак</title> <title>Верстак</title>
<style> <style>
/* ── reset & tokens ── */
*{box-sizing:border-box;margin:0;padding:0} *{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#1a1a2e;color:#ccc;height:100vh;display:flex;overflow:hidden} :root{
#sb{width:260px;min-width:200px;background:#16213e;border-right:1px solid #0f3460;display:flex;flex-direction:column;overflow:hidden} --bg:#13131f;--surface:#1b1b2e;--surface2:#24243a;
#sb-hdr{padding:14px 16px;font-size:16px;font-weight:700;color:#e94560;border-bottom:1px solid #0f3460;display:flex;justify-content:space-between;align-items:center} --border:#2a2a45;--border-hi:#3d3d5c;
#sb-hdr button{background:#0f3460;color:#ccc;border:1px solid #1a4080;border-radius:4px;padding:4px 10px;cursor:pointer;font-size:11px} --text:#e4e4ef;--text2:#b0b0c8;--text3:#8888a4;
#sb-hdr button:hover{background:#1a4080;color:#fff} --accent:#e94560;--accent2:#c73652;
--ok:#4caf50;--warn:#ff9800;
--font:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
--mono:"Fira Code","Consolas","SF Mono",monospace;
--sb-w:260px;--cnt-w:720px;
}
html,body{height:100%;overflow:hidden;font-family:var(--font);background:var(--bg);color:var(--text)}
button{font-family:inherit;cursor:pointer;border:none;font-size:inherit}
input,textarea,select{font-family:inherit;font-size:inherit;outline:none}
/* ── layout ── */
#app{display:flex;height:100vh}
#sb{width:var(--sb-w);min-width:200px;background:var(--surface);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden;flex-shrink:0}
#sb-hdr{padding:14px 16px;font-size:15px;font-weight:700;color:var(--accent);letter-spacing:.5px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;height:52px}
#sb-sections{padding:6px 0;border-bottom:1px solid var(--border)}
.sb-sect{padding:8px 16px 4px;font-size:10px;color:var(--text3);text-transform:uppercase;letter-spacing:1px;font-weight:600}
.sb-item{padding:6px 14px 6px 22px;cursor:pointer;display:flex;align-items:center;gap:8px;font-size:13px;color:var(--text2);border-radius:3px;margin:1px 6px}
.sb-item:hover{background:var(--border);color:var(--text)}
.sb-item.sel{background:var(--border-hi);color:var(--text);font-weight:500}
.sb-item .ic{width:16px;text-align:center;font-size:13px}
.sb-item .badge{margin-left:auto;font-size:10px;background:var(--accent);color:#fff;padding:1px 6px;border-radius:10px;min-width:18px;text-align:center}
#tree{flex:1;overflow-y:auto;padding:6px 0} #tree{flex:1;overflow-y:auto;padding:6px 0}
.ti{padding:5px 14px;cursor:pointer;display:flex;align-items:center;gap:6px;font-size:13px;border-radius:3px;margin:0 4px} #tree ul{list-style:none}
.ti:hover{background:#0f3460} .ti{padding:5px 14px;cursor:pointer;display:flex;align-items:center;gap:6px;font-size:13px;color:var(--text2);border-radius:3px;margin:0 4px}
.ti.sel{background:#1a4080;color:#fff} .ti:hover{background:var(--border);color:var(--text)}
.ti .ic{width:14px;color:#e94560;text-align:center;font-size:13px} .ti.sel{background:var(--border-hi);color:var(--text)}
.empty{padding:40px 14px;color:#555;font-size:13px;text-align:center} .ti .ic{width:14px;color:var(--accent);text-align:center;font-size:13px}
#main{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0} .ti .ar{width:12px;font-size:10px;color:var(--text3)}
#mh{padding:10px 20px;border-bottom:1px solid #0f3460;display:flex;justify-content:space-between;align-items:center;gap:10px} .tc{margin-left:20px}
.empty{padding:40px 14px;color:var(--text3);font-size:13px;text-align:center}
/* ── main panel ── */
#main{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0;background:var(--bg)}
#mh{padding:12px 24px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;gap:12px;height:52px}
#mh h1{font-size:17px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} #mh h1{font-size:17px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#mh .acts{display:flex;gap:5px;flex-shrink:0} #mh .actions{display:flex;gap:6px;flex-shrink:0}
#mh button{background:#0f3460;color:#ccc;border:1px solid #1a4080;border-radius:4px;padding:5px 11px;cursor:pointer;font-size:12px} .btn{background:var(--surface);color:var(--text2);border:1px solid var(--border);border-radius:5px;padding:6px 13px;font-size:12px;display:inline-flex;align-items:center;gap:5px}
#mh button:hover{background:#1a4080;color:#fff} .btn:hover{background:var(--border);color:var(--text)}
#mh button.p{background:#e94560;border-color:#e94560} .btn.primary{background:var(--accent);border-color:var(--accent);color:#fff}
#mh button.p:hover{background:#c73652} .btn.primary:hover{background:var(--accent2)}
#srch{padding:6px 20px;border-bottom:1px solid #0f3460} .btn.dd{position:relative}
#srch input{width:100%;padding:7px 12px;background:#0f0f23;color:#ccc;border:1px solid #0f3460;border-radius:5px;font-size:13px;outline:none} .btn.dd .menu{display:none;position:absolute;top:calc(100% + 4px);right:0;background:var(--surface2);border:1px solid var(--border);border-radius:6px;padding:4px;min-width:180px;z-index:80;box-shadow:0 8px 30px rgba(0,0,0,.5)}
#srch input:focus{border-color:#e94560} .btn.dd.open .menu{display:block}
#tabs{display:flex;padding:0 20px;border-bottom:1px solid #0f3460;gap:0;flex-shrink:0} .menu-item{padding:8px 14px;cursor:pointer;font-size:13px;color:var(--text2);border-radius:3px;display:flex;align-items:center;gap:8px}
#tabs .t{padding:9px 16px;cursor:pointer;font-size:13px;color:#666;border-bottom:2px solid transparent} .menu-item:hover{background:var(--border);color:var(--text)}
#tabs .t:hover{color:#ddd} .menu-item .mi-icon{width:16px;text-align:center;color:var(--accent)}
#tabs .t.a{color:#e94560;border-bottom-color:#e94560} .menu-sep{height:1px;background:var(--border);margin:4px 8px}
#cnt{flex:1;overflow-y:auto;padding:20px}
.cg{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px} /* ── search ── */
.card{background:#16213e;border:1px solid #0f3460;border-radius:7px;padding:14px;cursor:pointer;transition:border-color .15s} #srch{padding:6px 24px;border-bottom:1px solid var(--border);position:relative}
.card:hover{border-color:#e94560} #srch input{width:100%;padding:8px 14px;background:var(--surface);color:var(--text);border:1px solid var(--border);border-radius:6px;font-size:13px}
.card .ct{font-weight:600;margin-bottom:4px} #srch input:focus{border-color:var(--accent)}
.card .cy{font-size:11px;color:#777;text-transform:uppercase} #sr-res{display:none;position:absolute;top:46px;left:24px;right:24px;background:var(--surface2);border:1px solid var(--border);border-radius:8px;max-height:320px;overflow-y:auto;z-index:60;box-shadow:0 8px 30px rgba(0,0,0,.5)}
.card .cm{font-size:11px;color:#555;margin-top:6px} #sr-res:has(.sri){display:block}
.bc{font-size:12px;color:#555;margin-bottom:10px} .sri{padding:9px 14px;cursor:pointer;font-size:13px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px}
#ed{display:none;position:fixed;inset:0;background:#1a1a2e;z-index:90;flex-direction:column} .sri:last-child{border-bottom:none}
#ed-hdr{padding:10px 20px;border-bottom:1px solid #0f3460;display:flex;justify-content:space-between;align-items:center} .sri:hover{background:var(--border)}
#ed-hdr span{font-size:15px;font-weight:600} .sri .srt{font-size:10px;color:var(--accent);text-transform:uppercase;min-width:50px;font-weight:600}
#ed button{background:#0f3460;color:#ccc;border:1px solid #1a4080;border-radius:4px;padding:5px 12px;cursor:pointer;font-size:12px;margin-left:6px} .sri .sr-title{color:var(--text)}
#ed button.p{background:#e94560;border-color:#e94560}
#ed-ta{flex:1;background:#0f0f23;color:#ccc;border:none;padding:20px;font-family:Consolas,monospace;font-size:13px;line-height:1.7;resize:none;outline:none} /* ── tabs ── */
.mo{display:none;position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:100;align-items:center;justify-content:center} #tabs{display:flex;padding:0 24px;border-bottom:1px solid var(--border);gap:0;flex-shrink:0}
.tab{padding:10px 18px;cursor:pointer;font-size:13px;color:var(--text3);border-bottom:2px solid transparent;white-space:nowrap}
.tab:hover{color:var(--text)}
.tab.active{color:var(--accent);border-bottom-color:var(--accent)}
/* ── content ── */
#cnt{flex:1;overflow-y:auto;padding:24px}
#cnt-in{max-width:var(--cnt-w);margin:0 auto;width:100%}
/* ── dashboard ── */
.dash{margin-bottom:32px}
.dash h2{font-size:15px;font-weight:600;margin-bottom:12px;color:var(--text)}
.dash-section{margin-bottom:24px}
.dash-section-title{font-size:12px;color:var(--text3);text-transform:uppercase;letter-spacing:.8px;margin-bottom:8px;font-weight:600}
.dash-desc{font-size:14px;color:var(--text2);line-height:1.5;margin-bottom:12px}
.qa-grid{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px}
.qa-btn{background:var(--surface);border:1px solid var(--border);border-radius:6px;padding:8px 14px;font-size:12px;color:var(--text2);display:flex;align-items:center;gap:6px}
.qa-btn:hover{border-color:var(--accent);color:var(--text)}
.cg{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:12px}
.card{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:14px;cursor:pointer;transition:border-color .15s,background .15s}
.card:hover{border-color:var(--accent);background:var(--surface2)}
.card .ct{font-weight:600;margin-bottom:4px;font-size:14px;color:var(--text)}
.card .cy{font-size:11px;color:var(--text3);text-transform:uppercase;letter-spacing:.5px}
.card .cm{font-size:11px;color:var(--text3);margin-top:6px}
.card .cc{font-size:12px;color:var(--text2);margin-top:8px;line-height:1.4;overflow:hidden;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical}
.bc{font-size:12px;color:var(--text3);margin-bottom:12px}
.tag{display:inline-block;font-size:10px;padding:2px 7px;border-radius:4px;background:var(--border-hi);color:var(--text2);margin-right:4px;margin-bottom:4px}
.tag.accent{background:var(--accent);color:#fff}
/* ── editor ── */
#ed{display:none;position:fixed;inset:0;background:var(--bg);z-index:90;flex-direction:column}
#ed-hdr{padding:12px 24px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;height:52px}
#ed-crumb{font-size:12px;color:var(--text3)}
#ed-title{font-size:15px;font-weight:600;margin-top:2px}
#ed-actions{display:flex;gap:8px}
#ed-ta{flex:1;background:var(--bg);color:var(--text);border:none;padding:32px calc((100% - var(--cnt-w)) / 2);font-family:var(--mono);font-size:14px;line-height:1.8;resize:none}
#ed-ta:focus{outline:none}
/* ── modals ── */
.mo{display:none;position:fixed;inset:0;background:rgba(0,0,0,.65);z-index:100;align-items:center;justify-content:center}
.mo.on{display:flex} .mo.on{display:flex}
.md{background:#16213e;border:1px solid #0f3460;border-radius:10px;padding:22px;width:400px;max-width:90vw} .md{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:24px;width:420px;max-width:90vw;box-shadow:0 12px 40px rgba(0,0,0,.5)}
.md h3{margin-bottom:14px;font-size:15px} .md h3{margin-bottom:16px;font-size:16px}
.md label{display:block;font-size:11px;color:#777;margin-bottom:3px;margin-top:10px} .md label{display:block;font-size:12px;color:var(--text3);margin-bottom:4px;margin-top:12px;font-weight:500}
.md input,.md select{width:100%;padding:7px 10px;background:#0f0f23;color:#ccc;border:1px solid #1a4080;border-radius:4px;font-size:13px;outline:none} .md input,.md select,.md textarea{width:100%;padding:8px 12px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:5px;font-size:14px}
.md input:focus{border-color:#e94560} .md input:focus,.md select:focus{border-color:var(--accent)}
.md .ma{display:flex;gap:8px;justify-content:flex-end;margin-top:18px} .md textarea{min-height:80px;resize:vertical;font-family:var(--mono);font-size:13px}
.md button{padding:7px 14px;border-radius:4px;border:1px solid #1a4080;background:#0f3460;color:#ccc;cursor:pointer} .md .ma{display:flex;gap:8px;justify-content:flex-end;margin-top:20px}
.md button.p{background:#e94560;border-color:#e94560} .md .btn{padding:8px 16px}
#sr-res{display:none;position:absolute;background:#16213e;border:1px solid #0f3460;border-radius:6px;width:350px;max-height:300px;overflow-y:auto;z-index:50;box-shadow:0 4px 20px rgba(0,0,0,.4)}
#sr-res .sri{padding:8px 12px;cursor:pointer;font-size:13px;border-bottom:1px solid #0f3460} /* ── scrollbar ── */
#sr-res .sri:last-child{border-bottom:none} ::-webkit-scrollbar{width:6px;height:6px}
#sr-res .sri:hover{background:#0f3460} ::-webkit-scrollbar-track{background:transparent}
#sr-res .sri .srt{font-size:10px;color:#e94560;text-transform:uppercase} ::-webkit-scrollbar-thumb{background:var(--border-hi);border-radius:3px}
ul{list-style:none} ::-webkit-scrollbar-thumb:hover{background:var(--text3)}
</style> </style>
</head> </head>
<body> <body>
<div id="app">
<!-- sidebar -->
<div id="sb"> <div id="sb">
<div id="sb-hdr"><span>&#9874; ВЕРСТАК</span><button onclick="showCM()">+</button></div> <div id="sb-hdr"><span>&#9874; ВЕРСТАК</span><button class="btn primary" onclick="showAdd()" style="font-size:11px;padding:4px 10px">+ Добавить</button></div>
<div id="tree"><div class="empty">Загрузка...</div></div> <div id="sb-sections">
<div class="sb-sect">Навигация</div>
<div class="sb-item sel" data-g="today" onclick="goSec(this)"><span class="ic">&#9724;</span> Сегодня</div>
<div class="sb-item" data-g="inbox" onclick="goSec(this)"><span class="ic">&#9776;</span> Неразобранное <span class="badge" style="display:none">0</span></div>
<div class="sb-sect">Дела</div>
<div class="sb-item" data-g="clients" onclick="goSec(this)"><span class="ic">&#9874;</span> Клиенты</div>
<div class="sb-item" data-g="projects" onclick="goSec(this)"><span class="ic">&#9881;</span> Проекты</div>
<div class="sb-item" data-g="recipes" onclick="goSec(this)"><span class="ic">&#9886;</span> Рецепты</div>
<div class="sb-item" data-g="docs" onclick="goSec(this)"><span class="ic">&#128196;</span> Документы</div>
<div class="sb-item" data-g="archive" onclick="goSec(this)"><span class="ic">&#128450;</span> Архив</div>
</div> </div>
<div id="tree"><div class="empty">Дерево дел<br><small style="color:var(--text3)">появится автоматически</small></div></div>
</div>
<!-- main -->
<div id="main"> <div id="main">
<div id="mh"> <div id="mh">
<h1 id="pt">Верстак</h1> <h1 id="pt">Верстак</h1>
<div class="acts"> <div class="actions" id="mh-actions">
<button onclick="showCNM()">+ Заметка</button> <button class="btn dd" id="add-btn" onclick="toggleAdd(event)">
<button class="p" onclick="showCM()">+ Дело</button> + Добавить &#9662;
<div class="menu" id="add-menu">
<div class="menu-item" onclick="doAdd('case')"><span class="mi-icon">&#9670;</span> Дело</div>
<div class="menu-item" onclick="doAdd('note')"><span class="mi-icon">&#9997;</span> Заметка</div>
<div class="menu-item" onclick="doAdd('file')"><span class="mi-icon">&#128196;</span> Файл</div>
<div class="menu-sep"></div>
<div class="menu-item" onclick="doAdd('action')"><span class="mi-icon">&#9889;</span> Действие</div>
<div class="menu-item" onclick="doAdd('worklog')"><span class="mi-icon">&#9201;</span> Запись работы</div>
</div>
</button>
</div> </div>
</div> </div>
<div id="srch" style="position:relative"> <div id="srch">
<input id="si" placeholder="Поиск по делам..." autocomplete="off" oninput="handleSR(this.value)"> <input id="si" placeholder="Поиск по делам, заметкам, файлам..." autocomplete="off" oninput="handleSR(this.value)">
<div id="sr-res" style="top:38px;left:0"></div> <div id="sr-res"></div>
</div> </div>
<div id="tabs"> <div id="tabs">
<div class="t a" data-t="ov" onclick="switchTab(this)">Обзор</div> <div class="tab active" data-t="ov" onclick="switchTab(this)">Обзор</div>
<div class="t" data-t="nt" onclick="switchTab(this)">Заметки</div> <div class="tab" data-t="notes" onclick="switchTab(this)">Заметки</div>
<div class="t" data-t="fl" onclick="switchTab(this)">Файлы</div> <div class="tab" data-t="files" onclick="switchTab(this)">Файлы</div>
<div class="tab" data-t="actions" onclick="switchTab(this)">Действия</div>
<div class="tab" data-t="worklog" onclick="switchTab(this)">Журнал</div>
<div class="tab" data-t="activity" onclick="switchTab(this)">Активность</div>
</div> </div>
<div id="cnt"><div class="empty">Выберите дело или создайте новое</div></div> <div id="cnt"><div id="cnt-in"><div class="empty">Выберите раздел или дело</div></div></div>
</div> </div>
</div>
<!-- editor -->
<div id="ed"> <div id="ed">
<div id="ed-hdr"><span id="et">Редактор</span><div><button onclick="closeED()">Закрыть</button><button class="p" onclick="saveNT()">Сохранить</button></div></div> <div id="ed-hdr">
<div><div id="ed-crumb">Заметка</div><div id="ed-title">Редактор</div></div>
<div id="ed-actions">
<button class="btn" onclick="closeED()">Закрыть</button>
<button class="btn primary" onclick="saveNT()">Сохранить</button>
</div>
</div>
<textarea id="ed-ta" placeholder="Пишите в Markdown..."></textarea> <textarea id="ed-ta" placeholder="Пишите в Markdown..."></textarea>
</div> </div>
<div class="mo" id="cm">
<!-- modals -->
<div class="mo" id="m-node">
<div class="md"><h3>Новое дело</h3> <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 for="mn-type">Тип</label><select id="mn-type"><option value="case"> Дело</option><option value="folder">Папка</option><option value="space"> Пространство</option><option value="recipe"> Рецепт</option></select>
<label>Название</label><input id="cn" placeholder="Название..."> <label for="mn-title">Название</label><input id="mn-title" placeholder="Название...">
<div class="ma"><button onclick="closeCM()">Отмена</button><button class="p" onclick="submitCM()">Создать</button></div></div> <div class="ma"><button class="btn" onclick="closeM('m-node')">Отмена</button><button class="btn primary" onclick="submitNode()">Создать</button></div></div>
</div> </div>
<div class="mo" id="cnm"> <div class="mo" id="m-note">
<div class="md"><h3>Новая заметка</h3> <div class="md"><h3>Новая заметка</h3>
<label>Название</label><input id="cnn" placeholder="Название..."> <label for="mn2-title">Название</label><input id="mn2-title" placeholder="Название заметки...">
<div class="ma"><button onclick="closeCNM()">Отмена</button><button class="p" onclick="submitCNM()">Создать</button></div></div> <div class="ma"><button class="btn" onclick="closeM('m-note')">Отмена</button><button class="btn primary" onclick="submitNote()">Создать</button></div></div>
</div> </div>
<div class="mo" id="m-file">
<div class="md"><h3>Файл</h3>
<label style="color:var(--text3);font-size:13px;line-height:1.5">MVP: добавление файлов через интерфейс пока недоступно. Используйте CLI:<br><code style="background:var(--bg);padding:2px 6px;border-radius:3px;font-size:12px;color:var(--accent)">verstak file add &lt;path&gt; --node &lt;id&gt;</code></label>
<div class="ma"><button class="btn primary" onclick="closeM('m-file')">Понятно</button></div></div>
</div>
<div class="mo" id="m-action">
<div class="md"><h3>Действие</h3>
<label style="color:var(--text3);font-size:13px;line-height:1.5">MVP: создание действий пока недоступно в GUI. Используйте CLI:<br><code style="background:var(--bg);padding:2px 6px;border-radius:3px;font-size:12px;color:var(--accent)">verstak action add</code></label>
<div class="ma"><button class="btn primary" onclick="closeM('m-action')">Понятно</button></div></div>
</div>
<div class="mo" id="m-worklog">
<div class="md"><h3>Запись работы</h3>
<label for="mw-hours">Время (мин)</label><input id="mw-hours" type="number" placeholder="120" min="1">
<label for="mw-text">Описание</label><textarea id="mw-text" placeholder="Что сделано..."></textarea>
<label><input type="checkbox" id="mw-approx" checked style="width:auto;margin-right:6px"> примерно</label>
<div class="ma"><button class="btn" onclick="closeM('m-worklog')">Отмена</button><button class="btn primary" onclick="submitWorklog()">Записать</button></div></div>
</div>
<script> <script>
const A='';let cur='',editId=''; /* ── type labels ── */
async function api(p,o){const r=await fetch(A+p,{headers:{'Content-Type':'application/json'},...o});if(!r.ok)throw Error(r.status);return r.json()} const TL={case:'Дело',folder:'Папка',space:'Пространство',note:'Заметка',document:'Документ',file:'Файл',recipe:'Рецепт',action:'Действие',secret:'Секрет',worklog:'Журнал',link:'Ссылка'};
const NI={case:'&#9670;',folder:'&#9656;',space:'&#9678;',note:'&#9997;',recipe:'&#9672;',document:'&#128196;',file:'&#128206;',action:'&#9889;',secret:'&#128274;',worklog:'&#9201;',link:'&#128279;'};
/* ── state ── */
let cur='',sec='today',tab='ov';
/* ── api ── */
async function api(p,o){
const r=await fetch(location.origin+p,{headers:{'Content-Type':'application/json'},...o});
if(!r.ok)throw Error(r.status+' '+r.statusText);
return r.json();
}
function esc(s){let d=document.createElement('div');d.textContent=s;return d.innerHTML} function esc(s){let d=document.createElement('div');d.textContent=s;return d.innerHTML}
const ni={case:'&#9670;',folder:'&#9656;',space:'&#9678;',note:'&#9997;',recipe:'&#9672;',document:'&#128196;',file:'&#128206;',action:'&#9889;',secret:'&#128274;',worklog:'&#9201;',link:'&#128279;'};
async function loadTree(){ /* ── sidebar navigation ── */
try{const items=await api('/api/nodes');const t=document.getElementById('tree'); function goSec(el){
if(!items.length){t.innerHTML='<div class="empty">Создайте первое дело</div>';return} document.querySelectorAll('.sb-item').forEach(e=>e.classList.remove('sel'));
let h='<ul>';for(const n of items){h+='<li><div class="ti'+(cur===n.id?' sel':'')+'" onclick="selN(\''+n.id+'\')"><span class="ic">'+(ni[n.type]||'&#9670;')+'</span><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(n.title)+'</span></div></li>'} el.classList.add('sel');sec=el.dataset.g;
h+='</ul>';t.innerHTML=h} const g=sec;
catch(e){document.getElementById('tree').innerHTML='<div class="empty">Ошибка</div>'} document.getElementById('pt').textContent=el.childNodes[2]?.textContent?.trim()||sectTitle(g);
if(g==='today')renderToday();else if(g==='inbox')renderInbox();
else loadSection(g);
} }
async function selN(id){ function sectTitle(g){
cur=id;document.querySelectorAll('.ti').forEach(e=>e.classList.remove('sel')); return{clients:'Клиенты',projects:'Проекты',recipes:'Рецепты',docs:'Документы',archive:'Архив'}[g]||g;
const d=event.currentTarget;if(d)d.classList.add('sel');
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>'; async function loadSection(g){
if(ch.length||fl.length){h+='<div class="cg">'; map={'case':'clients','folder':'case'}; // map section to type filter
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 now just load all roots — filtering comes later
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>'} const items=await api('/api/nodes');
h+='</div>'}else{h+='<div class="empty" style="margin-top:60px">Пусто. Добавьте заметку или файл.</div>'} renderTree(items);
document.getElementById('cnt').innerHTML=h }
function renderTree(items){
const t=document.getElementById('tree');
if(!items.length){t.innerHTML='<div class="empty">Нет дел в разделе</div>';return}
let h='<ul>';for(const n of items){
h+='<li><div class="ti'+(cur===n.id?' sel':'')+'" data-id="'+n.id+'" onclick="selN(this)"><span class="ic">'+(NI[n.type]||'&#9670;')+'</span><span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(n.title)+'</span></div></li>';
}h+='</ul>';t.innerHTML=h;
}
/* ── node selection ── */
async function selN(el){
cur=el.dataset.id;document.querySelectorAll('.ti').forEach(e=>e.classList.remove('sel'));el.classList.add('sel');
try{
const d=await api('/api/nodes/'+cur);
document.getElementById('pt').textContent=d.node.title;
renderDash(d);
}catch(e){E('Ошибка загрузки')}
}
/* ═══ DASHBOARD (Обзор) ═══ */
function renderDash(d){
const n=d.node,ch=d.children||[],fl=d.files||[],notes=ch.filter(c=>c.type==='note'),sub=ch.filter(c=>c.type!=='note');
let h='';
// meta/desc row
if(n.type){h+='<div style="margin-bottom:16px"><span class="tag accent">'+TL[n.type]+'</span>';if(n.status)h+='<span class="tag">'+n.status+'</span>';h+='</div>'}
// quick actions
h+='<div class="dash-section"><div class="dash-section-title">Быстрые действия</div><div class="qa-grid">';
h+='<button class="qa-btn" onclick="openM(\'m-note\')">&#9997; Новая заметка</button>';
h+='<button class="qa-btn" onclick="openM(\'m-file\')">&#128196; Добавить файл</button>';
h+='<button class="qa-btn" onclick="openM(\'m-action\')">&#9889; Действие</button>';
h+='<button class="qa-btn" onclick="openM(\'m-worklog\')">&#9201; Записать время</button>';
h+='</div></div>';
// sub-items
if(sub.length){h+='<div class="dash-section"><div class="dash-section-title">Вложенные ('+sub.length+')</div><div class="cg">';
for(const c of sub)h+='<div class="card" onclick="selN(this)" data-id="'+c.id+'"><div class="ct">'+esc(c.title)+'</div><div class="cy">'+TL[c.type]+'</div></div>';
h+='</div></div>'}
// notes
if(notes.length){h+='<div class="dash-section"><div class="dash-section-title">Заметки ('+notes.length+')</div><div class="cg">';
for(const c of notes)h+='<div class="card" onclick="openNT(\''+c.id+'\')"><div class="ct">'+esc(c.title)+'</div><div class="cy">Заметка</div></div>';
h+='</div></div>'}
// files
if(fl.length){h+='<div class="dash-section"><div class="dash-section-title">Файлы ('+fl.length+')</div><div class="cg">';
for(const f of fl)h+='<div class="card"><div class="ct">'+esc(f.filename)+'</div><div class="cy">'+(TL['file']||f.mime||'Файл')+'</div><div class="cm">'+fsz(f.size)+'</div></div>';
h+='</div></div>'}
// empty
if(!sub.length&&!notes.length&&!fl.length){
h+='<div class="empty" style="margin-top:40px">Пусто. Начните с заметки или вложенного дела.</div>';
}
G('cnt-in').innerHTML=h;tab='ov';setTabs();
}
/* ═══ TABS ═══ */
function setTabs(){
document.querySelectorAll('.tab').forEach(t=>t.classList.toggle('active',t.dataset.t===tab));
} }
function fsz(b){if(b<1024)return b+' Б';if(b<1048576)return(b/1024).toFixed(1)+' КБ';return(b/1048576).toFixed(1)+' МБ'}
function switchTab(el){ function switchTab(el){
document.querySelectorAll('#tabs .t').forEach(e=>e.classList.remove('a'));el.classList.add('a'); tab=el.dataset.t;setTabs();
const t=el.dataset.t;if(t==='nt')loadNT();else if(t==='fl')loadFL();else if(cur)selN(cur); if(!cur){E('Выберите деле в дереве слева');return}
if(tab==='ov')selN(document.querySelector('.ti.sel')||{dataset:{id:cur},classList:{add(){}}});
else if(tab==='notes')loadNotesTab();
else if(tab==='files')loadFilesTab();
else if(tab==='actions')E('Действия в разработке');
else if(tab==='worklog')E('Журнал в разработке');
else if(tab==='activity')E('Активность в разработке');
} }
async function loadNT(){ function E(msg){G('cnt-in').innerHTML='<div class="empty" style="margin-top:60px">'+msg+'</div>'}
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'); async function loadNotesTab(){
if(!ns.length){document.getElementById('cnt').innerHTML='<div class="empty">Нет заметок</div>';return} if(!cur){E('Выберите дело');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>'; const d=await api('/api/nodes/'+cur);
document.getElementById('cnt').innerHTML=h const ns=(d.children||[]).filter(c=>c.type==='note');
if(!ns.length){E('Нет заметок. <button class="btn primary" onclick="openM(\'m-note\')" style="margin-top:8px">Создать</button>');return}
let h='<div class="cg">';
for(const n of ns)h+='<div class="card" onclick="openNT(\''+n.id+'\')"><div class="ct">&#9997; '+esc(n.title)+'</div><div class="cy">Заметка</div></div>';
h+='</div>';G('cnt-in').innerHTML=h;
} }
async function loadFL(){ async function loadFilesTab(){
if(!cur){document.getElementById('cnt').innerHTML='<div class="empty">Выберите дело</div>';return} if(!cur){E('Выберите дело');return}
const d=(await api('/api/nodes/'+cur)).files||[]; const d=(await api('/api/nodes/'+cur)).files||[];
if(!d.length){document.getElementById('cnt').innerHTML='<div class="empty">Нет файлов</div>';return} if(!d.length){E('Нет файлов. <button class="btn primary" onclick="openM(\'m-file\')" style="margin-top:8px">Добавить</button>');return}
let h='<div class="cg">';for(const f of d){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>'; let h='<div class="cg">';
document.getElementById('cnt').innerHTML=h for(const f of d)h+='<div class="card"><div class="ct">'+esc(f.filename)+'</div><div class="cy">'+(f.mime||'Файл')+'</div><div class="cm">'+fsz(f.size)+'</div></div>';
h+='</div>';G('cnt-in').innerHTML=h;
} }
/* ═══ EDITOR ═══ */
let editId='';
async function openNT(id){editId=id; 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';document.getElementById('ed-ta').focus()} try{const d=await api('/api/notes/'+id);G('ed-crumb').textContent='Заметка';G('ed-title').textContent='Редактирование';G('ed-ta').value=d.content||'';G('ed').style.display='flex';G('ed-ta').focus()}
catch(e){alert('Ошибка: '+e.message)} catch(e){alert('Ошибка: '+e.message)}
} }
function closeED(){document.getElementById('ed').style.display='none';editId=''} function closeED(){G('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)}} async function saveNT(){if(!editId)return;try{await api('/api/notes/'+editId,{method:'PUT',body:JSON.stringify({content:G('ed-ta').value})});closeED();if(cur)selN({dataset:{id:cur},classList:{add(){}}})}catch(e){alert('Ошибка: '+e.message)}}
function showCM(){document.getElementById('cm').classList.add('on');setTimeout(()=>document.getElementById('cn').focus(),50)}
function closeCM(){document.getElementById('cm').classList.remove('on');document.getElementById('cn').value=''} /* ═══ Navigation pages ═══ */
async function submitCM(){const t=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:t,title})});closeCM();loadTree();if(cur)selN(cur)}catch(e){alert('Ошибка: '+e.message)}} async function renderToday(){
function showCNM(){document.getElementById('cnm').classList.add('on');setTimeout(()=>document.getElementById('cnn').focus(),50)} const items=await api('/api/nodes');
function closeCNM(){document.getElementById('cnm').classList.remove('on');document.getElementById('cnn').value=''} let h='<div class="dash"><h2 style="font-size:22px;margin-bottom:4px">&#9724; Сегодня</h2>';
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)}catch(e){alert('Ошибка: '+e.message)}} h+='<div style="color:var(--text3);font-size:14px;margin-bottom:24px">'+new Date().toLocaleDateString('ru',{weekday:'long',day:'numeric',month:'long'})+'</div>';
if(items.length){
h+='<div class="dash-section"><div class="dash-section-title">Ваши дела</div><div class="cg">';
for(const n of items.slice(0,6)){h+='<div class="card" data-id="'+n.id+'" onclick="selN(this)"><div class="ct">'+esc(n.title)+'</div><div class="cy">'+TL[n.type]+'</div></div>'}
h+='</div></div>';
} else {h+='<div class="empty">Дел пока нет. Создайте первое!</div>'}
h+='</div>';G('cnt-in').innerHTML=h;
}
async function renderInbox(){
const items=await api('/api/nodes');
let h='<div class="dash"><h2 style="font-size:22px;margin-bottom:4px">&#9776; Неразобранное</h2>';
h+='<div style="color:var(--text3);font-size:14px;margin-bottom:24px">Файлы и заметки без дела</div>';
if(items.length){
h+='<div class="cg">';for(const n of items){h+='<div class="card" data-id="'+n.id+'" onclick="selN(this)"><div class="ct">'+esc(n.title)+'</div><div class="cy">'+TL[n.type]+'</div></div>'}h+='</div>';
} else {h+='<div class="empty">Всё разобрано!</div>'}
h+='</div>';G('cnt-in').innerHTML=h;
}
/* ═══ modals ═══ */
function openM(id){G(id).classList.add('on');setTimeout(()=>{const i=G(id).querySelector('input');if(i)i.focus()},60)}
function closeM(id){G(id).classList.remove('on');G(id).querySelectorAll('input,textarea').forEach(e=>e.value='')}
function doAdd(kind){closeAdd();if(kind==='case')openM('m-node');else if(kind==='note'){if(!cur&&sec==='today'){E('Выберите дело слева для заметки');return}openM('m-note')}else if(kind==='file')openM('m-file');else if(kind==='action')openM('m-action');else if(kind==='worklog')openM('m-worklog')}
async function submitNode(){const t=G('mn-type').value,title=G('mn-title').value.trim();if(!title)return;try{const n=await api('/api/nodes',{method:'POST',body:JSON.stringify({parent_id:cur,type:t,title})});closeM('m-node');renderTree(await api('/api/nodes'));selN({dataset:{id:n.id},classList:{add(){}}})}catch(e){alert('Ошибка: '+e.message)}}
async function submitNote(){const title=G('mn2-title').value.trim();if(!title)return;try{const n=await api('/api/notes/'+(cur||''),{method:'POST',body:JSON.stringify({parent_id:cur,title})});closeM('m-note');renderTree(await api('/api/nodes'));selN({dataset:{id:n.id},classList:{add(){}}})}catch(e){alert('Ошибка: '+e.message)}}
async function submitWorklog(){const mins=parseInt(G('mw-hours').value,10)||0;const text=G('mw-text').value.trim();const approx=G('mw-approw')?.checked?1:0;try{await api('/api/worklog',{method:'POST',body:JSON.stringify({node_id:cur,minutes:mins,summary:text,approximate:approx})});closeM('m-worklog');alert('Записано!')}catch(e){alert('Ошибка: '+e.message)}}
/* ═══ add menu dd ═══ */
function toggleAdd(e){e.stopPropagation();G('add-btn').classList.toggle('open')}
document.addEventListener('click',()=>{G('add-btn').classList.remove('open')});
/* ═══ search ═══ */
let sT=null; let sT=null;
async function handleSR(q){clearTimeout(sT);const b=document.getElementById('sr-res'); async function handleSR(q){clearTimeout(sT);const b=G('sr-res');
if(!q||q.length<2){b.style.display='none';return} if(!q||q.length<2){b.innerHTML='';return}
sT=setTimeout(async()=>{try{const items=await api('/api/nodes'); sT=setTimeout(async()=>{try{let items=await api('/api/nodes');
const hits=items.filter(n=>n.title.toLowerCase().includes(q.toLowerCase())); const hits=items.filter(n=>n.title.toLowerCase().includes(q.toLowerCase()));
if(!hits.length){b.style.display='none';return} if(!hits.length){b.innerHTML='';return}
let h='';for(const r of hits){h+='<div class="sri" onclick="b.style.display=\'none\';selN(\''+r.id+'\')"><div class="srt">'+r.type+'</div><div>'+esc(r.title)+'</div></div>'} let h='';for(const r of hits){h+='<div class="sri" data-id="'+r.id+'" onclick="selN(this);b.innerHTML=\'\'"><span class="srt">'+TL[r.type]+'</span><span class="sr-title">'+esc(r.title)+'</span></div>'}
b.innerHTML=h;b.style.display='block' b.innerHTML=h
}catch(e){b.style.display='none'}},200)} }catch(e){b.innerHTML=''}},200);
document.addEventListener('keydown',e=>{if(e.key==='Escape'){closeED();closeCM();closeCNM();document.getElementById('sr-res').style.display='none'}}); }
loadTree();
/* ═══ keyboard ═══ */
document.addEventListener('keydown',e=>{
if(e.key==='Escape'){closeED();document.querySelectorAll('.mo.on').forEach(m=>m.classList.remove('on'));G('sr-res').innerHTML=''}
if(e.key==='n'&&(e.ctrlKey||e.metaKey)){e.preventDefault();doAdd('case')}
if(e.key==='s'&&(e.ctrlKey||e.metaKey)&&editId){e.preventDefault();saveNT()}
});
/* ═══ util ═══ */
function G(id){return document.getElementById(id)}
function fsz(b){if(b==null||b===0)return'—';if(b<1024)return b+' Б';if(b<1048576)return(b/1024).toFixed(1)+' КБ';return(b/1048576).toFixed(1)+' МБ'}
/* ═══ init ═══ */
renderToday();
</script> </script>
</body> </body>
</html>` </html>`