852 lines
46 KiB
Go
852 lines
46 KiB
Go
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>⚒ ВЕРСТАК</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.5;margin-bottom:12px">Файл будет скопирован в vault и привязан к выбранному делу.</p>
|
||
<label>Или вставьте путь вручную</label>
|
||
<input id="mf-path" placeholder="/home/user/documents/file.pdf">
|
||
<div class="ma"><button class="btn" onclick="closeM('m-file')">Отмена</button><button class="btn primary" onclick="submitFile()">Добавить</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">◆</span> Дело</div>
|
||
<div class="menu-item" onclick="doAdd('note')"><span class="mi-icon">✍</span> Заметка</div>
|
||
<div class="menu-sep"></div>
|
||
<div class="menu-item" onclick="doAdd('file')"><span class="mi-icon">📄</span> Файл</div>
|
||
<div class="menu-item" onclick="doAdd('action')"><span class="mi-icon">⚡</span> Действие</div>
|
||
<div class="menu-item" onclick="doAdd('worklog')"><span class="mi-icon">⏱</span> Запись работы</div>
|
||
</div>
|
||
|
||
<script>
|
||
/* ════════════════════════════════════════════
|
||
TYPE LABELS
|
||
════════════════════════════════════════════ */
|
||
const TL={
|
||
case:'Дело',folder:'Папка',space:'Пространство',note:'Заметка',
|
||
document:'Документ',file:'Файл',recipe:'Рецепт',action:'Действие',
|
||
secret:'Секрет',worklog:'Журнал',link:'Ссылка'
|
||
};
|
||
const NI={
|
||
case:'◆',folder:'▸',space:'◎',note:'✍',
|
||
recipe:'◈',document:'📄',file:'📎',
|
||
action:'⚡',secret:'🔒',worklog:'⏱',link:'🔗'
|
||
};
|
||
|
||
/* ════════════════════════════════════════════
|
||
VIRTUAL SECTIONS (sidebar navigation)
|
||
════════════════════════════════════════════ */
|
||
const NAV=[
|
||
{id:'today', label:'Сегодня', icon:'📅'},
|
||
{id:'inbox', label:'Неразобранное', icon:'☰', badge:true},
|
||
{id:'clients', label:'Клиенты', icon:'⚒'},
|
||
{id:'projects', label:'Проекты', icon:'⚙'},
|
||
{id:'recipes', label:'Рецепты', icon:'⚞'},
|
||
{id:'documents', label:'Документы', icon:'📄'},
|
||
{id:'archive', label:'Архив', icon:'🗂'},
|
||
];
|
||
// 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]||'◆')+'</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>📅 '+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>☰ '+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\')">◆ '+esc(title.slice(0,-1))+'</button>';
|
||
h+='<button class="qa-btn" onclick="doAddSection(\''+section+'\',\'note\')">✍ Заметка</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\')">✍ Новая заметка</button>';
|
||
h+='<button class="qa-btn" onclick="openM(\'m-file\')">📄 Файл</button>';
|
||
h+='<button class="qa-btn" onclick="openM(\'m-action\')">⚡ Действие</button>';
|
||
h+='<button class="qa-btn" onclick="openM(\'m-worklog\')">⏱ Записать</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">✍ '+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)}
|
||
}
|
||
|
||
async function submitFile(){
|
||
const path=G('mf-path').value.trim();
|
||
if(!path)return;
|
||
if(!sel.nodeId){E('Выберите дело слева');return}
|
||
try{
|
||
const rec=await api('/api/files/upload',{method:'PUT',body:JSON.stringify({node_id:sel.nodeId,file_path:path,node_slug:''})});
|
||
closeM('m-file');
|
||
selectNode({dataset:{id:sel.nodeId}});
|
||
}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>`
|