verstak/internal/gui/index.html.go

838 lines
46 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package gui
// indexHTML — SPA-фронтенд Верстака (тёмная тема, левое дерево, вкладки).
// Весь UI в одном файле: CSS + HTML + JS. Wails-совместимость: структура
// готова к упаковке (нет external JS/CSS, fetch к /api/* через origin).
//
// navigation state:
// sel = { kind:'section', section:'today'|'inbox'|'clients'|'projects'|'recipes'|'documents'|'archive' }
// or { kind:'node', nodeId:'<uuid>' }
// tab = 'ov'|'notes'|'files'|'actions'|'worklog'|'activity'
const indexHTML = `<!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}
:root{
--bg:#13131f;--surface:#1b1b2e;--surface2:#24243a;
--border:#2a2a45;--border-hi:#3d3d5c;
--text:#e4e4ef;--text2:#b0b0c8;--text3:#8888a4;
--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-hdr .btn-primary{font-size:11px;padding:4px 10px;background:var(--accent);color:#fff;border-radius:4px}
#sb-hdr .btn-primary:hover{background:var(--accent2)}
.sb-label{padding:10px 16px 4px;font-size:10px;color:var(--text3);text-transform:uppercase;letter-spacing:1px;font-weight:600}
.sb-sep{height:1px;background:var(--border);margin:4px 0}
.sb-nav{padding:2px 0 6px}
.sb-item{padding:6px 14px 6px 16px;cursor:pointer;display:flex;align-items:center;gap:8px;font-size:13px;color:var(--text2);border-radius:4px;margin:1px 6px;border-left:2px solid transparent}
.sb-item:hover{background:var(--border);color:var(--text)}
.sb-item.active{background:var(--border-hi);color:var(--text);font-weight:500;border-left-color:var(--accent)}
.sb-item .ic{width:16px;text-align:center;font-size:13px;flex-shrink:0}
.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 (real nodes) */
#tree{flex:1;overflow-y:auto;padding:6px 0}
.tl-sect{cursor:pointer}
.tl-sect-hdr{padding:6px 14px 6px 14px;display:flex;align-items:center;gap:6px;font-size:13px;color:var(--text2);border-radius:3px;margin:0 4px;user-select:none}
.tl-sect-hdr:hover{background:var(--border);color:var(--text)}
.tl-sect-hdr .ar{width:10px;font-size:10px;color:var(--text3);flex-shrink:0;transition:transform .15s}
.tl-sect.open .tl-sect-hdr .ar{transform:rotate(90deg)}
.tl-sect-hdr .ic{width:14px;color:var(--accent);text-align:center;font-size:13px;flex-shrink:0}
.tl-sect-hdr .cnt{margin-left:auto;font-size:10px;color:var(--text3);background:var(--border-hi);padding:1px 6px;border-radius:8px}
.tl-children{margin-left:18px;display:none}
.tl-sect.open .tl-children{display:block}
.ti{padding:5px 14px 5px 18px;cursor:pointer;display:flex;align-items:center;gap:6px;font-size:13px;color:var(--text2);border-radius:3px;margin:0 4px 0 12px;border-left:2px solid transparent}
.ti:hover{background:var(--border);color:var(--text)}
.ti.active{background:var(--border-hi);color:var(--text);border-left-color:var(--accent)}
.ti .ic{width:14px;color:var(--accent);text-align:center;font-size:13px;flex-shrink:0}
.empty{padding:40px 14px;color:var(--text3);font-size:13px;text-align:center}
.empty .btn{display:inline-block;margin-top:12px;padding:8px 16px;background:var(--accent);color:#fff;border-radius:6px;font-size:13px;cursor:pointer}
.empty .btn:hover{background:var(--accent2)}
/* ── main ── */
#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}
.actions{display:flex;gap:6px;flex-shrink:0}
.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}
.btn:hover{background:var(--border);color:var(--text)}
.btn.primary{background:var(--accent);border-color:var(--accent);color:#fff}
.btn.primary:hover{background:var(--accent2)}
.dd-btn{position:relative}
.dd-btn .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)}
.dd-btn.open .menu{display:block}
.menu-item{padding:8px 14px;cursor:pointer;font-size:13px;color:var(--text2);border-radius:3px;display:flex;align-items:center;gap:8px}
.menu-item:hover{background:var(--border);color:var(--text)}
.menu-item .mi-icon{width:16px;text-align:center;color:var(--accent)}
.menu-sep{height:1px;background:var(--border);margin:4px 8px}
/* ── search ── */
#srch{padding:6px 24px;border-bottom:1px solid var(--border);position:relative}
#srch input{width:100%;padding:8px 14px;background:var(--surface);color:var(--text);border:1px solid var(--border);border-radius:6px;font-size:13px}
#srch input:focus{border-color:var(--accent)}
#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)}
#sr-res:has(.sri){display:block}
.sri{padding:9px 14px;cursor:pointer;font-size:13px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px}
.sri:last-child{border-bottom:none}
.sri:hover{background:var(--border)}
.sri .srt{font-size:10px;color:var(--accent);text-transform:uppercase;min-width:50px;font-weight:600}
.sri .sr-title{color:var(--text)}
/* ── tabs ── */
#tabs{display:flex;padding:0 24px;border-bottom:1px solid var(--border);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%}
.dash h2{font-size:22px;font-weight:600;margin-bottom:4px}
.dash .subtitle{font-size:14px;color:var(--text3);margin-bottom:24px}
.dash-section{margin-bottom:28px}
.dash-section-title{font-size:12px;color:var(--text3);text-transform:uppercase;letter-spacing:.8px;margin-bottom:10px;font-weight:600}
.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:inline-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;text-decoration:none}
.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}
.tag{display:inline-block;font-size:10px;padding:2px 7px;border-radius:4px;background:var(--border-hi);color:var(--text2);margin-right:4px}
.tag.accent{background:var(--accent);color:#fff}
input[type=checkbox]{width:auto!important;margin-right:6px;display:inline}
/* ── 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}
.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:16px;font-size:16px}
.md label{display:block;font-size:12px;color:var(--text3);margin-bottom:4px;margin-top:12px;font-weight:500}
.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,.md select:focus{border-color:var(--accent)}
.md textarea{min-height:80px;resize:vertical;font-family:var(--mono);font-size:13px}
.md .ma{display:flex;gap:8px;justify-content:flex-end;margin-top:20px}
.md .btn{padding:8px 16px}
/* ── scrollbar ── */
::-webkit-scrollbar{width:6px;height:6px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:var(--border-hi);border-radius:3px}
::-webkit-scrollbar-thumb:hover{background:var(--text3)}
</style>
</head>
<body>
<div id="app">
<!-- ══ sidebar ══ -->
<div id="sb">
<div id="sb-hdr">
<span>&#9874; ВЕРСТАК</span>
<button class="btn-primary" onclick="showAddMenu(event)">+ Добавить</button>
</div>
<div style="overflow-y:auto;flex:1">
<!-- virtual nav sections -->
<div class="sb-label">Навигация</div>
<div class="sb-nav" id="sb-nav"></div>
<div class="sb-sep"></div>
<!-- real tree -->
<div class="sb-label">Дела</div>
<div id="tree"><div class="empty">Загрузка...</div></div>
</div>
</div>
<!-- ══ main ══ -->
<div id="main">
<div id="mh">
<h1 id="pt">Верстак</h1>
</div>
<div id="srch">
<input id="si" placeholder="Поиск по делам, заметкам..." autocomplete="off" oninput="handleSR(this.value)">
<div id="sr-res"></div>
</div>
<div id="tabs">
<div class="tab active" data-t="ov" onclick="switchTab('ov')">Обзор</div>
<div class="tab" data-t="notes" onclick="switchTab('notes')">Заметки</div>
<div class="tab" data-t="files" onclick="switchTab('files')">Файлы</div>
<div class="tab" data-t="actions" onclick="switchTab('actions')">Действия</div>
<div class="tab" data-t="worklog" onclick="switchTab('worklog')">Журнал</div>
<div class="tab" data-t="activity" onclick="switchTab('activity')">Активность</div>
</div>
<div id="cnt"><div id="cnt-in"><div class="empty">Выберите раздел или дело</div></div></div>
</div>
</div>
<!-- ══ editor ══ -->
<div id="ed">
<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>
</div>
<!-- ══ modals ══ -->
<div class="mo" id="m-node">
<div class="md"><h3>Новое дело</h3>
<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 for="mn-title">Название</label><input id="mn-title" placeholder="Название...">
<label for="mn-parent">Родитель (опционально)</label><input id="mn-parent" placeholder="Имя папки или оставьте пустым">
<label>Шаблон</label>
<select id="mn-tmpl"><option value="">— без шаблона —</option></select>
<div class="ma"><button class="btn" onclick="closeM('m-node')">Отмена</button><button class="btn primary" onclick="submitNode()">Создать</button></div></div>
</div>
<div class="mo" id="m-note">
<div class="md"><h3>Новая заметка</h3>
<label for="mn2-title">Название</label><input id="mn2-title" placeholder="Название заметки...">
<div class="ma"><button class="btn" onclick="closeM('m-note')">Отмена</button><button class="btn primary" onclick="submitNote()">Создать</button></div></div>
</div>
<div class="mo" id="m-file">
<div class="md"><h3>Файл</h3>
<p style="color:var(--text3);font-size:13px;line-height:1.6">MVP: добавление файлов через интерфейс пока недоступно.</p>
<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>
<p style="color:var(--text3);font-size:13px;line-height:1.6">MVP: создание действий пока недоступно в GUI.</p>
<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>
<!-- add menu (dropdown) -->
<div id="add-menu" style="display:none;position:fixed;background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:4px;min-width:200px;z-index:110;box-shadow:0 8px 30px rgba(0,0,0,.5)">
<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-sep"></div>
<div class="menu-item" onclick="doAdd('file')"><span class="mi-icon">&#128196;</span> Файл</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>
<script>
/* ════════════════════════════════════════════
TYPE LABELS
════════════════════════════════════════════ */
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;'
};
/* ════════════════════════════════════════════
VIRTUAL SECTIONS (sidebar navigation)
════════════════════════════════════════════ */
const NAV=[
{id:'today', label:'Сегодня', icon:'&#128197;'},
{id:'inbox', label:'Неразобранное', icon:'&#9776;', badge:true},
{id:'clients', label:'Клиенты', icon:'&#9874;'},
{id:'projects', label:'Проекты', icon:'&#9881;'},
{id:'recipes', label:'Рецепты', icon:'&#9886;'},
{id:'documents', label:'Документы', icon:'&#128196;'},
{id:'archive', label:'Архив', icon:'&#128450;'},
];
// sectionId -> {label, emptyMessage, emptyHint, emptyAction}
const SEC_META={
today: {label:'Сегодня', empty:'Сегодня пока нет активности', hint:'Открытые сегодня дела появятся здесь', action:'case'},
inbox: {label:'Неразобранное', empty:'Всё разобрано', hint:'Файлы и заметки без дела появятся здесь', action:'case'},
clients: {label:'Клиенты', empty:'Пока нет клиентов', hint:'Создайте первое клиентское дело', action:'case'},
projects: {label:'Проекты', empty:'Пока нет проектов', hint:'Создайте первый проект', action:'case'},
recipes: {label:'Рецепты', empty:'Пока нет рецептов', hint:'Создайте первый рецепт', action:'recipe'},
documents: {label:'Документы', empty:'Пока нет документов', hint:'Создайте первую документальную область', action:'space'},
archive: {label:'Архив', empty:'Архив пуст', hint:'Архивированные дела появятся здесь', action:'case'},
};
/* ════════════════════════════════════════════
STATE
════════════════════════════════════════════ */
// selection: either {kind:'section', section:'today'|'inbox'|...}
// or {kind:'node', nodeId:'<uuid>'}
let sel = {kind:'section', section:'today'};
let tab = 'ov'; // ov|notes|files|actions|worklog|activity
let editId = '';
let nodeCache = {}; // id -> {detail, ts}
/* ════════════════════════════════════════════
API + helpers
════════════════════════════════════════════ */
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 G(id){return document.getElementById(id)}
function fsz(b){if(!b||b===0)return'—';if(b<1024)return b+' Б';if(b<1048576)return(b/1024).toFixed(1)+' КБ';return(b/1048576).toFixed(1)+' МБ'}
function setCnt(html){G('cnt-in').innerHTML=html}
function EC(label,hint,actionType){
const typ = actionType||'case';
return '<div class="empty" style="margin-top:60px">'+esc(label)+
(hint?'<br><span style="color:var(--text3);font-size:12px">'+esc(hint)+'</span>':'')+
'<br><button class="btn primary" style="margin-top:12px" onclick="doAdd(\''+typ+'\')">+ Создать</button></div>';
}
/* ════════════════════════════════════════════
SIDEBAR
════════════════════════════════════════════ */
function renderNav(){
let h='';
for(const n of NAV){
const isActive = sel.kind==='section' && sel.section===n.id;
h+='<div class="sb-item'+(isActive?' active':'')+'" data-s="'+n.id+'" onclick="selectSection(\''+n.id+'\')">';
h+='<span class="ic">'+n.icon+'</span><span style="flex:1">'+n.label+'</span>';
if(n.badge)h+='<span class="badge" id="badge-'+n.id+'"></span>';
h+='</div>';
}
G('sb-nav').innerHTML=h;
}
// Build tree from real nodes. Group top-level nodes; no section-based filtering
// is possible from backend yet (no type column). We build a flat "top-level" list.
function renderTree(items){
const t=G('tree');
if(!items.length){t.innerHTML='<div class="empty" style="font-size:12px">Нет дел. Создайте первое через "+ Добавить".</div>';return}
// sort folders first
const nodes=items.slice().sort((a,b)=>{if(a.type==='folder'&&b.type!=='folder')return-1;if(b.type==='folder'&&a.type!=='folder')return 1;return a.title.localeCompare(b.title)});
let h='<ul>';
for(const n of nodes){
const isActive = sel.kind==='node' && sel.nodeId===n.id;
h+='<li><div class="ti'+(isActive?' active':'')+'" data-id="'+n.id+'" onclick="selectNode(this)">';
h+='<span class="ic">'+(NI[n.type]||'&#9670;')+'</span>';
h+='<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(n.title)+'</span>';
h+='</div></li>';
}
h+='</ul>';t.innerHTML=h;
}
/* ════════════════════════════════════════════
SELECTION
════════════════════════════════════════════ */
function selectSection(id){
sel={kind:'section', section:id};
renderNav(); // re-render to update active highlight
const m=SEC_META[id]||{label:id};
G('pt').textContent=m.label;
tab='ov';renderTabs();
renderSectionContent(id);
// reload tree to remove stale highlights
try{api('/api/nodes').then(renderTree)}catch(e){}
}
function selectNode(el){
const id=el.dataset.id;
sel={kind:'node', nodeId:id};
renderTabs();
// cache detail — fetch if needed
const cached=nodeCache[id];
if(cached&&Date.now()-cached.ts<10000){
renderNodeDash(cached.detail);
renderTreeFromCache(id);
}else{
G('pt').textContent='Загрузка...';
api('/api/nodes/'+id).then(d=>{
nodeCache[id]={detail:d,ts:Date.now()};
renderNodeDash(d);
// clear stale tree highlights (don't re-fetch — just update classes)
document.querySelectorAll('.ti').forEach(e=>e.classList.toggle('active',e.dataset.id===id));
}).catch(()=>E('Ошибка загрузки'));
}
}
function renderTreeFromCache(activeId){
document.querySelectorAll('.ti').forEach(e=>e.classList.toggle('active',e.dataset.id===activeId));
}
/* ════════════════════════════════════════════
SECTION RENDERERS — each section loads only
its own nodes via ?section= filter.
════════════════════════════════════════════ */
async function renderSectionContent(section){
const m=SEC_META[section]||{};
const title=m.label||section;
switch(section){
case 'today': return renderSectionToday(title);
case 'inbox': return renderSectionInbox(title);
case 'clients': return renderSectionList(title, 'clients');
case 'projects': return renderSectionList(title, 'projects');
case 'recipes': return renderSectionList(title, 'recipes');
case 'documents': return renderSectionList(title, 'documents');
case 'archive': return renderSectionList(title, 'archive');
default: return E('Неизвестный раздел');
}
}
async function renderSectionToday(title){
let items=[];
try{items=await api('/api/nodes?section=')}catch(e){}
let h='<div class="dash"><h2>&#128197; '+esc(title)+'</h2>';
h+='<div class="subtitle">'+new Date().toLocaleDateString('ru',{weekday:'long',day:'numeric',month:'long',year:'numeric'})+'</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,8)){
h+='<div class="card" data-id="'+n.id+'" onclick="selectNode(this)"><div class="ct">'+esc(n.title)+'</div><div class="cy">'+TL[n.type]+'</div></div>';
}
h+='</div></div>';
} else {
h+=EC(SEC_META.today.empty, SEC_META.today.hint);
}
h+='</div>';setCnt(h);
}
async function renderSectionInbox(title){
let items=[];
try{items=await api('/api/nodes?section=inbox')}catch(e){}
let h='<div class="dash"><h2>&#9776; '+esc(title)+'</h2>';
h+='<div class="subtitle">Элементы без категории</div>';
if(items.length){
h+='<div class="dash-section"><div class="dash-section-title">Неразобранные дела</div><div class="cg">';
for(const n of items){
h+='<div class="card" data-id="'+n.id+'" onclick="selectNode(this)"><div class="ct">'+esc(n.title)+'</div><div class="cy">'+TL[n.type]+'</div></div>';
}
h+='</div></div>';
} else {
h+=EC(SEC_META.inbox.empty,'','case');
}
h+='</div>';setCnt(h);
}
async function renderSectionList(title, section){
let items=[];
const qs = section==='inbox' ? 'inbox' : section;
try{items=await api('/api/nodes?section='+encodeURIComponent(qs))}catch(e){}
let h='<div class="dash"><h2>'+esc(title)+'</h2>';
h+='<div class="subtitle">'+esc(title)+'</div>';
h+='<div class="dash-section">';
h+='<div class="qa-grid" style="margin-bottom:20px">';
h+='<button class="qa-btn" onclick="doAddSection(\''+section+'\',\'case\')">&#9670; '+esc(title.slice(0,-1))+'</button>';
h+='<button class="qa-btn" onclick="doAddSection(\''+section+'\',\'note\')">&#9997; Заметка</button>';
h+='</div>';
if(items.length){
h+='<div class="dash-section-title">'+esc(title)+' ('+items.length+')</div><div class="cg">';
for(const n of items){
h+='<div class="card" data-id="'+n.id+'" onclick="selectNode(this)"><div class="ct">'+esc(n.title)+'</div><div class="cy">'+TL[n.type]+'</div></div>';
}
h+='</div>';
} else {
const m=SEC_META[section]||{};
h+='<div class="empty" style="margin-top:30px"><p>'+esc(m.empty||'Пусто')+'</p>';
if(m.hint)h+='<p style="font-size:12px;color:var(--text3);margin-top:4px">'+esc(m.hint)+'</p>';
h+='<button class="btn primary" style="margin-top:12px" onclick="doAddSection(\''+section+'\',\''+(m.action||'case')+'\')">+ Создать</button></div>';
}
h+='</div></div>';setCnt(h);
}
/* ════════════════════════════════════════════
NODE DASHBOARD (when a real node is selected)
════════════════════════════════════════════ */
function renderNodeDash(d){
const n=d.node,ch=d.children||[],fl=d.files||[];
const notes=ch.filter(c=>c.type==='note');
const subs=ch.filter(c=>c.type!=='note');
let h='<div class="dash"><h2>'+esc(n.title)+'</h2>';
h+='<div class="subtitle">';
// quick actions
h+='<div class="dash-section"><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>';
if(subs.length){
h+='<div class="dash-section"><div class="dash-section-title">Вложенные</div><div class="cg">';
for(const c of subs)h+='<div class="card" data-id="'+c.id+'" onclick="selectNode(this)"><div class="ct">'+esc(c.title)+'</div><div class="cy">'+TL[c.type]+'</div></div>';
h+='</div></div>';
}
if(notes.length){
h+='<div class="dash-section"><div class="dash-section-title">Заметки</div><div class="cg">';
for(const c of notes)h+='<div class="card" data-id="'+c.id+'" onclick="openNT(\''+c.id+'\')"><div class="ct">'+esc(c.title)+'</div><div class="cy">Заметка</div></div>';
h+='</div></div>';
}
if(fl.length){
h+='<div class="dash-section"><div class="dash-section-title">Файлы</div><div class="cg">';
for(const f of fl)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></div>';
}
if(!subs.length&&!notes.length&&!fl.length){
h+=EC('Пусто. Начните с заметки или вложенного дела.','','note');
}
h+='</div>';setCnt(h);
}
/* ════════════════════════════════════════════
TAB SWITCHING — key dispatch
════════════════════════════════════════════ */
function renderTabs(){
document.querySelectorAll('.tab').forEach(t=>t.classList.toggle('active',t.dataset.t===tab));
}
function switchTab(t){
tab=t;renderTabs();
if(sel.kind==='node') return switchTabNode(t);
if(sel.kind==='section') return switchTabSection(t);
}
function switchTabNode(t){
const id=sel.nodeId;
if(!id){E('Выберите дело в дереве слева');return}
if(t==='ov'){
const c=nodeCache[id];
if(c)renderNodeDash(c.detail);
else api('/api/nodes/'+id).then(d=>{nodeCache[id]={detail:d,ts:Date.now()};renderNodeDash(d)});
}else if(t==='notes') loadNodeNotes(id);
else if(t==='files') loadNodeFiles(id);
else if(t==='actions') loadNodeActions(id);
else if(t==='worklog') loadNodeWorklog(id);
else if(t==='activity') setCnt('<div class="empty" style="margin-top:60px">Активность — в разработке</div>');
}
function switchTabSection(t){
const sec=sel.section;
if(t==='ov'){renderSectionContent(sec);return}
// For section context, tabs show filtered views
if(t==='notes') setCnt('<div class="empty" style="margin-top:60px">Заметки раздела — в разработке</div>');
else if(t==='files') setCnt('<div class="empty" style="margin-top:60px">Файлы раздела — в разработке</div>');
else if(t==='actions') setCnt('<div class="empty" style="margin-top:60px">Действия — в разработке</div>');
else if(t==='worklog') setCnt('<div class="empty" style="margin-top:60px">Журнал — в разработке</div>');
else if(t==='activity') setCnt('<div class="empty" style="margin-top:60px">Активность — в разработке</div>');
}
async function loadNodeNotes(nodeId){
try{
const d=await api('/api/nodes/'+nodeId);
nodeCache[nodeId]={detail:d,ts:Date.now()};
const ns=(d.children||[]).filter(c=>c.type==='note');
if(!ns.length){setCnt('<div class="empty" style="margin-top:60px">Нет заметок. <button class="btn primary" onclick="openM(\'m-note\')" style="margin-top:8px">Создать</button></div>');return}
let h='<div class="cg">';for(const n of ns)h+='<div class="card" data-id="'+n.id+'" onclick="openNT(\''+n.id+'\')"><div class="ct">&#9997; '+esc(n.title)+'</div><div class="cy">Заметка</div></div>';
h+='</div>';setCnt(h);
}catch(e){E('Ошибка')}
}
async function loadNodeFiles(nodeId){
const cached=nodeCache[nodeId];
const d = cached ? cached.detail : await api('/api/nodes/'+nodeId);
if(!cached)nodeCache[nodeId]={detail:d,ts:Date.now()};
const fl=d.files||[];
if(!fl.length){setCnt('<div class="empty" style="margin-top:60px">Нет файлов</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||'Файл')+'</div><div class="cm">'+fsz(f.size)+'</div></div>';
h+='</div>';setCnt(h);
}
function E(msg){setCnt('<div class="empty" style="margin-top:60px">'+esc(msg)+'</div>')}
async function loadNodeActions(nodeId){
try{
const list=await api('/api/actions?node='+nodeId);
if(!list.length){setCnt('<div class="empty" style="margin-top:60px">Нет действий. <button class="btn primary" style="margin-top:8px" onclick="openM(\'m-action\')">+ Добавить</button></div>');return}
let h='<div style="display:flex;flex-direction:column;gap:10px">';
for(const a of list){
h+='<div style="display:flex;align-items:center;gap:12px;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:14px 16px">';
h+='<button class="btn primary" style="min-width:120px;justify-content:center" onclick="runActionConfirm(\''+a.id+'\',\''+esc(a.title)+'\',\''+a.kind+'\','+a.confirm_required+')">'+esc(a.title)+'</button>';
h+='<span style="font-size:12px;color:var(--text3);flex:1">'+esc(a.kind||'')+'</span>';
h+='<button class="btn" onclick="delAction(\''+a.id+'\')" title="Удалить" style="padding:4px 10px">✕</button>';
h+='</div>';
}
h+='</div>';setCnt(h);
}catch(e){E('Ошибка')}
}
const AL={open_url:'URL',open_file:'Файл',open_folder:'Папка',run_command:'Команда',run_script:'Скрипт',open_terminal:'Терминал',launch_app:'Приложение'};
function runActionConfirm(id,title,kind,confirm){
if(!confirm){runActionExec(id);return}
const lbl=AL[kind]||kind;
G('ed-crumb').textContent='Действие: '+title;
G('ed-title').textContent='Подтверждение';
G('ed-ta').value='Тип: '+lbl+'\n\nВыполнить действие «'+title+'»?';
G('ed').style.display='flex';
editId='__action__'+id;
}
async function runActionExec(id){
if(id.startsWith('__action__'))id=id.slice(10);
try{
const r=await api('/api/actions/'+id,{method:'POST',body:'{}'});
closeED();
if(r&&r.output)alert(r.output);
}catch(e){alert('Ошибка: '+e.message)}
}
async function delAction(id){
if(!confirm('Удалить действие?'))return;
try{
await api('/api/actions/'+id,{method:'DELETE'});
if(sel.kind==='node')loadNodeActions(sel.nodeId);
}catch(e){alert('Ошибка: '+e.message)}
}
/* ════════════════════════════════════════════
EDITOR
════════════════════════════════════════════ */
async function openNT(id){editId=id;
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)}
}
function closeED(){G('ed').style.display='none';editId=''}
async function saveNT(){
if(!editId)return;
if(editId.startsWith('__action__')){
G('ed').style.display='none';
await runActionExec(editId);
editId='';
return;
}
try{await api('/api/notes/'+editId,{method:'PUT',body:JSON.stringify({content:G('ed-ta').value})});closeED();}catch(e){alert('Ошибка: '+e.message)}
}
/* ════════════════════════════════════════════
WORKLOG TAB
════════════════════════════════════════════ */
async function loadNodeWorklog(nodeId){
try{
const list=await api('/api/worklog?node='+nodeId);
let h='<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">';
h+='<button class="btn primary" onclick="toggleWLEntry()">+ Добавить запись</button>';
const total=list.reduce((s,e)=>s+(e.minutes||0),0);
h+='<span style="font-size:13px;color:var(--text3)">Итого: '+Math.floor(total/60)+'ч '+total%60+'м</span>';
h+='</div>';
h+='<div id="wl-add" style="display:none;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:16px;margin-bottom:16px">';
h+='<label>Время (мин)</label><input id="wl-min" type="number" placeholder="120" style="margin-bottom:8px">';
h+='<label>Описание</label><textarea id="wl-text" placeholder="Что сделано..." style="min-height:60px;margin-bottom:8px"></textarea>';
h+='<label><input type="checkbox" id="wl-approx" checked style="width:auto;margin-right:6px"> примерно</label>';
h+='<div style="display:flex;gap:8px;margin-top:12px"><button class="btn" onclick="toggleWLEntry()">Отмена</button><button class="btn primary" onclick="submitWLEntry(\''+nodeId+'\')">Записать</button></div>';
h+='</div>';
if(!list.length){h+='<div class="empty" style="margin-top:40px">Нет записей</div>';setCnt(h);return}
h+='<div style="display:flex;flex-direction:column;gap:8px">';
for(const e of list){
const dur=e.minutes?e.minutes+'м':'—';
const approx=e.approximate?' ~':'';
h+='<div style="background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:14px 16px">';
h+='<div style="display:flex;justify-content:space-between;margin-bottom:4px"><span style="font-size:12px;color:var(--text3)">'+e.date+'</span><span style="font-size:13px;font-weight:600">'+dur+approx+'</span></div>';
h+='<div style="font-size:14px">'+esc(e.summary)+'</div>';
if(e.details)h+='<div style="font-size:12px;color:var(--text3);margin-top:4px">'+esc(e.details)+'</div>';
h+='</div>';
}
h+='</div>';setCnt(h);
}catch(e){E('Ошибка')}
}
function toggleWLEntry(){
const el=document.getElementById('wl-add');
if(el)el.style.display=el.style.display==='none'?'block':'none';
}
async function submitWLEntry(nodeId){
const mins=parseInt(document.getElementById('wl-min').value,10)||0;
const text=document.getElementById('wl-text').value.trim();
const approx=document.getElementById('wl-approx').checked;
if(!text)return;
try{
await api('/api/worklog/',{method:'POST',body:JSON.stringify({node_id:nodeId,summary:text,minutes,approximate:approx})});
toggleWLEntry();
loadNodeWorklog(nodeId);
}catch(e){alert('Ошибка: '+e.message)}
}
/* ════════════════════════════════════════════
MODALS
════════════════════════════════════════════ */
function openM(id){
G(id).classList.add('on');
setTimeout(()=>{const i=G(id).querySelector('input,textarea');if(i)i.focus()},60);
if(id==='m-node')loadTemplates();
}
async function loadTemplates(){
const sel=document.getElementById('mn-tmpl');
if(!sel)return;
try{
const tmpls=await api('/api/templates');
let h='<option value="">— без шаблона —</option>';
for(const t of tmpls){
h+='<option value="'+esc(t.name)+'">'+esc(t.name)+' ['+esc(t.plugin)+']</option>';
}
sel.innerHTML=h;
}catch(e){}
}
function closeM(id){G(id).classList.remove('on');G(id).querySelectorAll('input,textarea').forEach(e=>e.value='')}
function doAdd(kind){
closeAddMenu();
if(kind==='case')openM('m-node');
else if(kind==='note'){
if(sel.kind==='node')openM('m-note');
else if(sel.kind==='section'&&(sel.section==='today'||sel.section==='inbox'||sel.section==='clients'||sel.section==='projects')){
openM('m-note');
}else{E('Выберите дело слева для заметки');return}
}
else if(kind==='file')openM('m-file');
else if(kind==='action')openM('m-action');
else if(kind==='worklog')openM('m-worklog');
}
function doAddSection(section, kind){
closeAddMenu();
// create node directly in the section (no modal)
const title = prompt('Название:');
if(!title||!title.trim())return;
submitSectionNode(section, kind, title.trim());
}
async function submitSectionNode(section, kind, title){
const body = {parent_id:'', type:kind, title};
if(section && section!=='today' && section!=='inbox') body.section=section;
try{
const n = await api('/api/nodes',{method:'POST',body:JSON.stringify(body)});
closeM('m-node');
const items=await api('/api/nodes?section='+encodeURIComponent(section||''));
if(sel.section)renderSectionContent(sel.section); else renderTree(items);
selectNode({dataset:{id:n.id}});
}catch(e){alert('Ошибка: '+e.message)}
}
function showAddMenu(e){
e.stopPropagation();
const m=G('add-menu');
const rect=e.currentTarget.getBoundingClientRect();
m.style.display='block';
m.style.top=(rect.bottom+4)+'px';
m.style.left=rect.left+'px';
}
function closeAddMenu(){G('add-menu').style.display='none'}
document.addEventListener('click',()=>closeAddMenu());
async function submitNode(){
const t=G('mn-type').value,title=G('mn-title').value.trim(),parentName=G('mn-parent').value.trim();
const tmpl=G('mn-tmpl').value;
if(!title)return;
let parentId='', section='';
if(parentName){
try{const items=await api('/api/nodes?section=');
const found=items.find(n=>n.title.toLowerCase().startsWith(parentName.toLowerCase()));
if(found)parentId=found.id;
}catch(e){}
}else if(sel.kind==='node'){parentId=sel.nodeId;}
else if(sel.kind==='section' && sel.section!=='today' && sel.section!=='inbox'){
section=sel.section;
}
try{
let n;
if(tmpl){
n=await api('/api/nodes/from-template',{method:'POST',body:JSON.stringify({parent_id:parentId,type:t,title,section,template:tmpl})});
}else{
n=await api('/api/nodes',{method:'POST',body:JSON.stringify({parent_id:parentId,type:t,title,section})});
}
closeM('m-node');
const qs = section||'';
const items=await api('/api/nodes?section='+encodeURIComponent(qs));
if(sel.section){renderSectionContent(sel.section);}else{renderTree(items);}
selectNode({dataset:{id:n.id}});
}catch(e){alert('Ошибка: '+e.message)}
}
async function submitNote(){
const title=G('mn2-title').value.trim();
if(!title)return;
let parentId='', section='';
if(sel.kind==='node')parentId=sel.nodeId;
else if(sel.kind==='section' && sel.section!=='today' && sel.section!=='inbox' && sel.section!=='archive'){
section=sel.section;
}else{return E('Выберите дело для заметки')}
try{
const body = {parent_id:parentId, title};
if(section) body.section=section;
const n=await api('/api/notes/'+(parentId||''),{method:'POST',body:JSON.stringify(body)});
closeM('m-note');
const qs = section||'';
const items=await api('/api/nodes?section='+encodeURIComponent(qs));
if(sel.section){renderSectionContent(sel.section);}else{renderTree(items);}
selectNode({dataset:{id:n.id}});
}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-approx')?.checked?1:0;
try{
await api('/api/worklog',{method:'POST',body:JSON.stringify({node_id:sel.nodeId||'',minutes:mins,summary:text,approximate:approx})});
closeM('m-worklog');alert('Записано!');
}catch(e){alert('Ошибка: '+e.message)}
}
/* ════════════════════════════════════════════
SEARCH
════════════════════════════════════════════ */
function selectBySearch(id){
G('sr-res').innerHTML='';
const el=document.querySelector('.ti[data-id="'+id+'"]');
if(el)el.click(); else selectNode({dataset:{id}});
}
let sT=null;
async function handleSR(q){
clearTimeout(sT);const b=G('sr-res');
if(!q||q.length<2){b.innerHTML='';return}
sT=setTimeout(async()=>{
try{
const items=await api('/api/search?q='+encodeURIComponent(q));
if(!items||!items.length){b.innerHTML='';return}
let h='';
for(const r of items){
h+='<div class="sri" data-id="'+r.id+'" onclick="selectBySearch(\''+r.id+'\')"><span class="srt">'+(TL[r.type]||r.type||'')+'</span><span class="sr-title">'+esc(r.title)+'</span></div>';
}
b.innerHTML=h;
}catch(e){b.innerHTML=''}
},200);
}
/* ════════════════════════════════════════════
KEYBOARD
════════════════════════════════════════════ */
document.addEventListener('keydown',e=>{
if(e.key==='Escape'){closeED();document.querySelectorAll('.mo.on').forEach(m=>m.classList.remove('on'));G('sr-res').innerHTML='';closeAddMenu()}
if(e.key==='n'&&(e.ctrlKey||e.metaKey)){e.preventDefault();doAdd('case')}
if(e.key==='s'&&(e.ctrlKey||e.metaKey)&&editId){e.preventDefault();saveNT()}
});
/* ════════════════════════════════════════════
INIT
════════════════════════════════════════════ */
renderNav();
api('/api/nodes').then(items=>{renderTree(items)}).catch(()=>{G('tree').innerHTML='<div class="empty">Не удалось загрузить дела</div>'});
renderSectionContent('today');
</script>
</body>
</html>`