steps 4-6 + doc overhaul: files, notes, GUI, plugins docs
DOCUMENTATION (shift from personal to universal product): - README.md: rewritten with 'one product, different doors' framing, universal entities table, audience segments - 01_Product_Spec.md: removed personal references (sshkeeper, Godot, DokuWiki, servers), added audience segments (freelancer, repairmaster, developer, maker, consultant), universal scenarios - 02_Architecture.md: added 'Plugins (Extensibility)' section with calendar/kanban/importer/template examples - 03_Data_Model_Storage.md: added section 6 on plugin extensibility (node_meta, type registry, SQL migrations per plugin) - 09_Extensibility.md (NEW): full plugin architecture — Lua runtime, plugin.json, hooks, sandbox, templates, registry - PLAN.md: added step 16 (plugins), updated status table - 00_README.md: rewritten product index with plugin principle CODE — STEP 4 (Files): - migration 002: files table (id, node_id, filename, path, storage_mode, size, sha256, mime, ...) - FileService: AddExternal, CopyIntoVault, Get, ListByNode, MarkMissing, DeleteToTrash, Open (xdg-open) - file_test.go: 5 tests (external, copy-vault, list-node, delete-trash, MIME guess) CODE — STEP 5 (Notes): - migration 003: notes table (node_id PK, file_id, format, original_format, encrypted) - NoteService: Create (node+file+link), Read, Save (with backup to .verstak/history/), Delete, Load - note_test.go: 3 tests (create-read, save-backup, delete) CODE — STEP 6 (GUI): - cmd/verstak-gui/main.go: launches GUI server, opens browser - internal/gui/server.go: HTTP API for nodes/notes/files/search - internal/gui/index.html.go: full inline SPA frontend (dark theme, sidebar tree, cards grid, note editor, search, create modals) - Navigation: sidebar tree → click node → detail view with children + files cards → tab switch (overview/notes/files) → create node/note via modal → edit note in fullscreen textarea → save (with history backup) Acceptance: go build ./... pass, go build -tags gui ./cmd/verstak-gui pass, go test ./... pass (20+ tests). GUI serves on random port, opens browser. API returns JSON for all resource types.
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
package gui
|
||||
|
||||
// indexHTML is the GUI frontend served inline.
|
||||
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}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#1a1a2e;color:#ccc;height:100vh;display:flex;overflow:hidden}
|
||||
#sb{width:260px;min-width:200px;background:#16213e;border-right:1px solid #0f3460;display:flex;flex-direction:column;overflow:hidden}
|
||||
#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}
|
||||
#sb-hdr button{background:#0f3460;color:#ccc;border:1px solid #1a4080;border-radius:4px;padding:4px 10px;cursor:pointer;font-size:11px}
|
||||
#sb-hdr button:hover{background:#1a4080;color:#fff}
|
||||
#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}
|
||||
.ti:hover{background:#0f3460}
|
||||
.ti.sel{background:#1a4080;color:#fff}
|
||||
.ti .ic{width:14px;color:#e94560;text-align:center;font-size:13px}
|
||||
.empty{padding:40px 14px;color:#555;font-size:13px;text-align:center}
|
||||
#main{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0}
|
||||
#mh{padding:10px 20px;border-bottom:1px solid #0f3460;display:flex;justify-content:space-between;align-items:center;gap:10px}
|
||||
#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 button{background:#0f3460;color:#ccc;border:1px solid #1a4080;border-radius:4px;padding:5px 11px;cursor:pointer;font-size:12px}
|
||||
#mh button:hover{background:#1a4080;color:#fff}
|
||||
#mh button.p{background:#e94560;border-color:#e94560}
|
||||
#mh button.p:hover{background:#c73652}
|
||||
#srch{padding:6px 20px;border-bottom:1px solid #0f3460}
|
||||
#srch input{width:100%;padding:7px 12px;background:#0f0f23;color:#ccc;border:1px solid #0f3460;border-radius:5px;font-size:13px;outline:none}
|
||||
#srch input:focus{border-color:#e94560}
|
||||
#tabs{display:flex;padding:0 20px;border-bottom:1px solid #0f3460;gap:0;flex-shrink:0}
|
||||
#tabs .t{padding:9px 16px;cursor:pointer;font-size:13px;color:#666;border-bottom:2px solid transparent}
|
||||
#tabs .t:hover{color:#ddd}
|
||||
#tabs .t.a{color:#e94560;border-bottom-color:#e94560}
|
||||
#cnt{flex:1;overflow-y:auto;padding:20px}
|
||||
.cg{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px}
|
||||
.card{background:#16213e;border:1px solid #0f3460;border-radius:7px;padding:14px;cursor:pointer;transition:border-color .15s}
|
||||
.card:hover{border-color:#e94560}
|
||||
.card .ct{font-weight:600;margin-bottom:4px}
|
||||
.card .cy{font-size:11px;color:#777;text-transform:uppercase}
|
||||
.card .cm{font-size:11px;color:#555;margin-top:6px}
|
||||
.bc{font-size:12px;color:#555;margin-bottom:10px}
|
||||
#ed{display:none;position:fixed;inset:0;background:#1a1a2e;z-index:90;flex-direction:column}
|
||||
#ed-hdr{padding:10px 20px;border-bottom:1px solid #0f3460;display:flex;justify-content:space-between;align-items:center}
|
||||
#ed-hdr span{font-size:15px;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}
|
||||
#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}
|
||||
.mo{display:none;position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:100;align-items:center;justify-content:center}
|
||||
.mo.on{display:flex}
|
||||
.md{background:#16213e;border:1px solid #0f3460;border-radius:10px;padding:22px;width:400px;max-width:90vw}
|
||||
.md h3{margin-bottom:14px;font-size:15px}
|
||||
.md label{display:block;font-size:11px;color:#777;margin-bottom:3px;margin-top:10px}
|
||||
.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:focus{border-color:#e94560}
|
||||
.md .ma{display:flex;gap:8px;justify-content:flex-end;margin-top:18px}
|
||||
.md button{padding:7px 14px;border-radius:4px;border:1px solid #1a4080;background:#0f3460;color:#ccc;cursor:pointer}
|
||||
.md button.p{background:#e94560;border-color:#e94560}
|
||||
#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}
|
||||
#sr-res .sri:last-child{border-bottom:none}
|
||||
#sr-res .sri:hover{background:#0f3460}
|
||||
#sr-res .sri .srt{font-size:10px;color:#e94560;text-transform:uppercase}
|
||||
ul{list-style:none}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="sb">
|
||||
<div id="sb-hdr"><span>⚒ ВЕРСТАК</span><button onclick="showCM()">+</button></div>
|
||||
<div id="tree"><div class="empty">Загрузка...</div></div>
|
||||
</div>
|
||||
<div id="main">
|
||||
<div id="mh">
|
||||
<h1 id="pt">Верстак</h1>
|
||||
<div class="acts">
|
||||
<button onclick="showCNM()">+ Заметка</button>
|
||||
<button class="p" onclick="showCM()">+ Дело</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="srch" style="position:relative">
|
||||
<input id="si" placeholder="Поиск по делам..." autocomplete="off" oninput="handleSR(this.value)">
|
||||
<div id="sr-res" style="top:38px;left:0"></div>
|
||||
</div>
|
||||
<div id="tabs">
|
||||
<div class="t a" data-t="ov" onclick="switchTab(this)">Обзор</div>
|
||||
<div class="t" data-t="nt" onclick="switchTab(this)">Заметки</div>
|
||||
<div class="t" data-t="fl" onclick="switchTab(this)">Файлы</div>
|
||||
</div>
|
||||
<div id="cnt"><div class="empty">Выберите дело или создайте новое</div></div>
|
||||
</div>
|
||||
<div id="ed">
|
||||
<div id="ed-hdr"><span id="et">Редактор</span><div><button onclick="closeED()">Закрыть</button><button class="p" onclick="saveNT()">Сохранить</button></div></div>
|
||||
<textarea id="ed-ta" placeholder="Пишите в Markdown..."></textarea>
|
||||
</div>
|
||||
<div class="mo" id="cm">
|
||||
<div class="md"><h3>Новое дело</h3>
|
||||
<label>Тип</label><select id="ct"><option value="case">Дело</option><option value="folder">Папка</option><option value="space">Пространство</option><option value="recipe">Рецепт</option></select>
|
||||
<label>Название</label><input id="cn" placeholder="Название...">
|
||||
<div class="ma"><button onclick="closeCM()">Отмена</button><button class="p" onclick="submitCM()">Создать</button></div></div>
|
||||
</div>
|
||||
<div class="mo" id="cnm">
|
||||
<div class="md"><h3>Новая заметка</h3>
|
||||
<label>Название</label><input id="cnn" placeholder="Название...">
|
||||
<div class="ma"><button onclick="closeCNM()">Отмена</button><button class="p" onclick="submitCNM()">Создать</button></div></div>
|
||||
</div>
|
||||
<script>
|
||||
const A='';let cur='',editId='';
|
||||
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()}
|
||||
function esc(s){let d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
||||
const ni={case:'◆',folder:'▸',space:'◎',note:'✍',recipe:'◈',document:'📄',file:'📎',action:'⚡',secret:'🔒',worklog:'⏱',link:'🔗'};
|
||||
async function loadTree(){
|
||||
try{const items=await api('/api/nodes');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':'')+'" onclick="selN(\''+n.id+'\')"><span class="ic">'+(ni[n.type]||'◆')+'</span><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(n.title)+'</span></div></li>'}
|
||||
h+='</ul>';t.innerHTML=h}
|
||||
catch(e){document.getElementById('tree').innerHTML='<div class="empty">Ошибка</div>'}
|
||||
}
|
||||
async function selN(id){
|
||||
cur=id;document.querySelectorAll('.ti').forEach(e=>e.classList.remove('sel'));
|
||||
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>';
|
||||
if(ch.length||fl.length){h+='<div class="cg">';
|
||||
for(const c of ch){h+='<div class="card" onclick="selN(\''+c.id+'\')"><div class="ct">'+esc(c.title)+'</div><div class="cy">'+c.type+'</div></div>'}
|
||||
for(const f of fl){h+='<div class="card"><div class="ct">'+esc(f.filename)+'</div><div class="cy">'+(f.mime||'file')+'</div><div class="cm">'+fsz(f.size)+'</div></div>'}
|
||||
h+='</div>'}else{h+='<div class="empty" style="margin-top:60px">Пусто. Добавьте заметку или файл.</div>'}
|
||||
document.getElementById('cnt').innerHTML=h
|
||||
}
|
||||
function fsz(b){if(b<1024)return b+' Б';if(b<1048576)return(b/1024).toFixed(1)+' КБ';return(b/1048576).toFixed(1)+' МБ'}
|
||||
function switchTab(el){
|
||||
document.querySelectorAll('#tabs .t').forEach(e=>e.classList.remove('a'));el.classList.add('a');
|
||||
const t=el.dataset.t;if(t==='nt')loadNT();else if(t==='fl')loadFL();else if(cur)selN(cur);
|
||||
}
|
||||
async function loadNT(){
|
||||
if(!cur){document.getElementById('cnt').innerHTML='<div class="empty">Выберите дело</div>';return}
|
||||
const d=await api('/api/nodes/'+cur);const ns=(d.children||[]).filter(c=>c.type==='note');
|
||||
if(!ns.length){document.getElementById('cnt').innerHTML='<div class="empty">Нет заметок</div>';return}
|
||||
let h='<div class="cg">';for(const n of ns){h+='<div class="card" onclick="openNT(\''+n.id+'\')"><div class="ct">'+esc(n.title)+'</div><div class="cy">заметка</div></div>'}h+='</div>';
|
||||
document.getElementById('cnt').innerHTML=h
|
||||
}
|
||||
async function loadFL(){
|
||||
if(!cur){document.getElementById('cnt').innerHTML='<div class="empty">Выберите дело</div>';return}
|
||||
const d=(await api('/api/nodes/'+cur)).files||[];
|
||||
if(!d.length){document.getElementById('cnt').innerHTML='<div class="empty">Нет файлов</div>';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>';
|
||||
document.getElementById('cnt').innerHTML=h
|
||||
}
|
||||
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()}
|
||||
catch(e){alert('Ошибка: '+e.message)}
|
||||
}
|
||||
function closeED(){document.getElementById('ed').style.display='none';editId=''}
|
||||
async function saveNT(){if(!editId)return;try{await api('/api/notes/'+editId,{method:'PUT',body:JSON.stringify({content:document.getElementById('ed-ta').value})});closeED();if(cur)selN(cur)}catch(e){alert('Ошибка: '+e.message)}}
|
||||
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=''}
|
||||
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)}}
|
||||
function showCNM(){document.getElementById('cnm').classList.add('on');setTimeout(()=>document.getElementById('cnn').focus(),50)}
|
||||
function closeCNM(){document.getElementById('cnm').classList.remove('on');document.getElementById('cnn').value=''}
|
||||
async function submitCNM(){const title=document.getElementById('cnn').value.trim();if(!title)return;try{const n=await api('/api/notes/'+(cur||''),{method:'POST',body:JSON.stringify({parent_id:cur,title})});closeCNM();loadTree();selN(n.id)}catch(e){alert('Ошибка: '+e.message)}}
|
||||
let sT=null;
|
||||
async function handleSR(q){clearTimeout(sT);const b=document.getElementById('sr-res');
|
||||
if(!q||q.length<2){b.style.display='none';return}
|
||||
sT=setTimeout(async()=>{try{const items=await api('/api/nodes');
|
||||
const hits=items.filter(n=>n.title.toLowerCase().includes(q.toLowerCase()));
|
||||
if(!hits.length){b.style.display='none';return}
|
||||
let h='';for(const r of hits){h+='<div class="sri" onclick="b.style.display=\'none\';selN(\''+r.id+'\')"><div class="srt">'+r.type+'</div><div>'+esc(r.title)+'</div></div>'}
|
||||
b.innerHTML=h;b.style.display='block'
|
||||
}catch(e){b.style.display='none'}},200)}
|
||||
document.addEventListener('keydown',e=>{if(e.key==='Escape'){closeED();closeCM();closeCNM();document.getElementById('sr-res').style.display='none'}});
|
||||
loadTree();
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
@@ -0,0 +1,249 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"verstak/internal/core/files"
|
||||
"verstak/internal/core/notes"
|
||||
"verstak/internal/core/nodes"
|
||||
"verstak/internal/core/storage"
|
||||
)
|
||||
|
||||
// Server is the GUI HTTP server bound to a vault.
|
||||
type Server struct {
|
||||
db *storage.DB
|
||||
vaultRoot string
|
||||
nodes *nodes.Repository
|
||||
files *files.Service
|
||||
notes *notes.Service
|
||||
srv *http.Server
|
||||
listener net.Listener
|
||||
port int
|
||||
}
|
||||
|
||||
// NewServer creates a GUI server for the given vault.
|
||||
func NewServer(db *storage.DB, vaultRoot string) *Server {
|
||||
nodeRepo := nodes.NewRepository(db)
|
||||
fileSvc := files.NewService(db, vaultRoot)
|
||||
noteSvc := notes.NewService(db, vaultRoot, nodeRepo, fileSvc)
|
||||
return &Server{
|
||||
db: db, vaultRoot: vaultRoot,
|
||||
nodes: nodeRepo, files: fileSvc, notes: noteSvc,
|
||||
}
|
||||
}
|
||||
|
||||
// Start binds on a free port and returns the base URL.
|
||||
func (s *Server) Start() (string, error) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/nodes", s.handleNodes)
|
||||
mux.HandleFunc("/api/nodes/", s.handleNodeDetail)
|
||||
mux.HandleFunc("/api/notes/", s.handleNotes)
|
||||
mux.HandleFunc("/api/files/", s.handleFiles)
|
||||
mux.HandleFunc("/api/search", s.handleSearch)
|
||||
mux.HandleFunc("/", s.handleStatic)
|
||||
|
||||
s.srv = &http.Server{Handler: mux}
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.listener = ln
|
||||
s.port = ln.Addr().(*net.TCPAddr).Port
|
||||
go func() {
|
||||
if e := s.srv.Serve(ln); e != nil && e != http.ErrServerClosed {
|
||||
log.Printf("GUI: %v", e)
|
||||
}
|
||||
}()
|
||||
return fmt.Sprintf("http://127.0.0.1:%d", s.port), nil
|
||||
}
|
||||
|
||||
// Stop shuts down the server.
|
||||
func (s *Server) Stop() error {
|
||||
if s.srv != nil {
|
||||
return s.srv.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Addr returns the base URL.
|
||||
func (s *Server) Addr() string {
|
||||
return fmt.Sprintf("http://127.0.0.1:%d", s.port)
|
||||
}
|
||||
|
||||
// --- handlers ---
|
||||
|
||||
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" && r.URL.Path != "/index.html" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
t := template.Must(template.New("idx").Parse(indexHTML))
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
t.Execute(w, nil)
|
||||
}
|
||||
|
||||
func jsonOK(w http.ResponseWriter, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func jsonErr(w http.ResponseWriter, code int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// GET /api/nodes[?parent=ID] POST /api/nodes
|
||||
func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
parent := r.URL.Query().Get("parent")
|
||||
var list interface{}
|
||||
var err error
|
||||
if parent == "" {
|
||||
list, err = s.nodes.ListRoots(false)
|
||||
} else {
|
||||
list, err = s.nodes.ListChildren(parent, false)
|
||||
}
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, list)
|
||||
case "POST":
|
||||
var req struct {
|
||||
ParentID string `json:"parent_id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonErr(w, 400, "bad json")
|
||||
return
|
||||
}
|
||||
n, err := s.nodes.Create(req.ParentID, req.Type, req.Title)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, n)
|
||||
default:
|
||||
jsonErr(w, 405, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
// GET/PUT/DELETE /api/nodes/{id}
|
||||
func (s *Server) handleNodeDetail(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimPrefix(r.URL.Path, "/api/nodes/")
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
n, err := s.nodes.GetActive(id)
|
||||
if err != nil {
|
||||
jsonErr(w, 404, "not found")
|
||||
return
|
||||
}
|
||||
children, _ := s.nodes.ListChildren(id, false)
|
||||
fl, _ := s.files.ListByNode(id)
|
||||
meta, _ := s.nodes.MetaList(id)
|
||||
jsonOK(w, map[string]interface{}{
|
||||
"node": n, "children": children, "files": fl, "meta": meta,
|
||||
})
|
||||
case "PUT":
|
||||
var req struct {
|
||||
Title string `json:"title"`
|
||||
ParentID string `json:"parent_id"`
|
||||
Sort int `json:"sort_order"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
if req.Title != "" {
|
||||
s.nodes.UpdateTitle(id, req.Title)
|
||||
}
|
||||
if req.ParentID != "" {
|
||||
s.nodes.Move(id, req.ParentID, req.Sort)
|
||||
}
|
||||
jsonOK(w, map[string]string{"status": "ok"})
|
||||
case "DELETE":
|
||||
s.nodes.SoftDelete(id)
|
||||
jsonOK(w, map[string]string{"status": "deleted"})
|
||||
default:
|
||||
jsonErr(w, 405, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/notes/{parentID} PUT/GET /api/notes/{nodeID}
|
||||
func (s *Server) handleNotes(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/notes/")
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
rec, err := s.notes.Load(path)
|
||||
if err != nil {
|
||||
jsonErr(w, 404, "not found")
|
||||
return
|
||||
}
|
||||
content, _ := s.notes.Read(path)
|
||||
jsonOK(w, map[string]interface{}{"record": rec, "content": content})
|
||||
case "POST":
|
||||
var req struct{ Title string `json:"title"` }
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
n, _, err := s.notes.Create(path, req.Title)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, n)
|
||||
case "PUT":
|
||||
var req struct{ Content string `json:"content"` }
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
if err := s.notes.Save(path, req.Content); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]string{"status": "saved"})
|
||||
default:
|
||||
jsonErr(w, 405, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
// GET/DELETE /api/files/{id}
|
||||
func (s *Server) handleFiles(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimPrefix(r.URL.Path, "/api/files/")
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
rec, err := s.files.Get(id)
|
||||
if err != nil {
|
||||
jsonErr(w, 404, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, rec)
|
||||
case "DELETE":
|
||||
if err := s.files.DeleteToTrash(id); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]string{"status": "trashed"})
|
||||
default:
|
||||
jsonErr(w, 405, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/search?q=...
|
||||
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
q := strings.ToLower(r.URL.Query().Get("q"))
|
||||
if len(q) < 2 {
|
||||
jsonOK(w, []interface{}{})
|
||||
return
|
||||
}
|
||||
roots, _ := s.nodes.ListRoots(false)
|
||||
var hits []map[string]interface{}
|
||||
for _, n := range roots {
|
||||
if strings.Contains(strings.ToLower(n.Title), q) {
|
||||
hits = append(hits, map[string]interface{}{"id": n.ID, "title": n.Title, "type": n.Type})
|
||||
}
|
||||
}
|
||||
jsonOK(w, hits)
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Верстак</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#1a1a2e;color:#e0e0e0;height:100vh;display:flex;overflow:hidden}
|
||||
#sb{width:280px;background:#16213e;border-right:1px solid #0f3460;display:flex;flex-direction:column;overflow:hidden;flex-shrink:0}
|
||||
#sb-hdr{padding:16px;font-size:18px;font-weight:700;color:#e94560;border-bottom:1px solid #0f3460;display:flex;justify-content:space-between;align-items:center}
|
||||
#sb-hdr button{background:#0f3460;color:#e0e0e0;border:1px solid #1a4080;border-radius:4px;padding:4px 10px;cursor:pointer;font-size:12px}
|
||||
#sb-hdr button:hover{background:#1a4080}
|
||||
#tree{flex:1;overflow-y:auto;padding:8px 0}
|
||||
#tree ul{list-style:none}
|
||||
#tree li{user-select:none}
|
||||
.ti{padding:6px 16px;cursor:pointer;display:flex;align-items:center;gap:8px;font-size:13px;border-radius:4px;margin:1px 6px}
|
||||
.ti:hover{background:#0f3460}
|
||||
.ti.sel{background:#1a4080;color:#fff}
|
||||
.ti .ic{width:16px;text-align:center;color:#e94560;font-size:14px}
|
||||
.ti .ar{width:12px;font-size:10px;color:#555}
|
||||
.tc{margin-left:18px;border-left:1px dashed #0f3460}
|
||||
#main{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0}
|
||||
#mh{padding:14px 24px;border-bottom:1px solid #0f3460;display:flex;justify-content:space-between;align-items:center;gap:12px}
|
||||
#mh h1{font-size:18px;font-weight:600;white-space:overflow:hidden;text-overflow:ellipsis}
|
||||
#mh .acts{display:flex;gap:6px;flex-shrink:0}
|
||||
#mh button{background:#0f3460;color:#e0e0e0;border:1px solid #1a4080;border-radius:4px;padding:6px 12px;cursor:pointer;font-size:12px}
|
||||
#mh button:hover{background:#1a4080}
|
||||
#mh button.p{background:#e94560;border-color:#e94560;color:#fff}
|
||||
#mh button.p:hover{background:#c73652}
|
||||
#tabs{display:flex;padding:0 24px;border-bottom:1px solid #0f3460;flex-shrink:0}
|
||||
#tabs .t{padding:10px 18px;cursor:pointer;font-size:13px;color:#888;border-bottom:2px solid transparent;white-space:nowrap}
|
||||
#tabs .t:hover{color:#e0e0e0}
|
||||
#tabs .t.a{color:#e94560;border-bottom-color:#e94560}
|
||||
#srch{padding:8px 24px}
|
||||
#srch input{width:100%;padding:8px 12px;background:#0f0f23;color:#e0e0e0;border:1px solid #0f3460;border-radius:6px;font-size:13px;outline:none}
|
||||
#srch input:focus{border-color:#e94560}
|
||||
#sr-res{display:none;position:absolute;top:100px;left:50%;transform:translateX(-50%);width:400px;max-width:90vw;background:#16213e;border:1px solid #0f3460;border-radius:8px;max-height:350px;overflow-y:auto;z-index:60}
|
||||
#sr-res .sri{padding:10px 14px;cursor:pointer;border-bottom:1px solid #0f3460;font-size:13px}
|
||||
#sr-res .sri:hover{background:#0f3460}
|
||||
#sr-res .sri .srt{font-size:10px;color:#e94560;text-transform:uppercase}
|
||||
#cnt{flex:1;overflow-y:auto;padding:24px}
|
||||
.bc{font-size:12px;color:#666;margin-bottom:12px}
|
||||
.empty{display:flex;align-items:center;justify-content:center;height:100%;color:#555;font-size:15px}
|
||||
.cg{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px}
|
||||
.card{background:#16213e;border:1px solid #0f3460;border-radius:8px;padding:14px;cursor:pointer;transition:border-color .15s}
|
||||
.card:hover{border-color:#e94560}
|
||||
.card .ct{font-weight:600;margin-bottom:4px;font-size:14px}
|
||||
.card .cy{font-size:11px;color:#888;text-transform:uppercase}
|
||||
.card .cm{font-size:11px;color:#555;margin-top:6px}
|
||||
#ed{display:none;position:fixed;inset:0;background:#1a1a2e;z-index:90;flex-direction:column}
|
||||
#ed-hdr{padding:12px 24px;border-bottom:1px solid #0f3460;display:flex;justify-content:space-between;align-items:center}
|
||||
#ed-hdr span{font-size:16px;font-weight:600}
|
||||
#ed button{background:#0f3460;color:#e0e0e0;border:1px solid #1a4080;border-radius:4px;padding:6px 14px;cursor:pointer;font-size:12px}
|
||||
#ed button.p{background:#e94560;border-color:#e94560}
|
||||
#ed-ta{flex:1;background:#0f0f23;color:#e0e0e0;border:none;padding:24px;font-family:"Fira Code",Consolas,monospace;font-size:14px;line-height:1.7;resize:none;outline:none}
|
||||
#ed-ftr{padding:10px 24px;border-top:1px solid #0f3460;display:flex;gap:8px}
|
||||
.mo{display:none;position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:100;align-items:center;justify-content:center}
|
||||
.mo.on{display:flex}
|
||||
.md{background:#16213e;border:1px solid #0f3460;border-radius:12px;padding:24px;width:420px;max-width:90vw}
|
||||
.md h3{margin-bottom:16px;font-size:16px}
|
||||
.md label{display:block;font-size:12px;color:#888;margin-bottom:4px;margin-top:12px}
|
||||
.md input,.md select{width:100%;padding:8px 12px;background:#0f0f23;color:#e0e0e0;border:1px solid #1a4080;border-radius:4px;font-size:14px;outline:none}
|
||||
.md input:focus{border-color:#e94560}
|
||||
.md .ma{display:flex;gap:8px;justify-content:flex-end;margin-top:20px}
|
||||
.md button{padding:8px 16px;border-radius:4px;border:1px solid #1a4080;background:#0f3460;color:#e0e0e0;cursor:pointer}
|
||||
.md button.p{background:#e94560;border-color:#e94560;color:#fff}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="sb">
|
||||
<div id="sb-hdr"><span>⚒ ВЕРСТАК</span><button onclick="showCM()">+</button></div>
|
||||
<div id="tree"><div class="empty" style="padding:30px 14px;font-size:13px">Пусто. Создайте первое дело.</div></div>
|
||||
</div>
|
||||
|
||||
<div id="main">
|
||||
<div id="mh"><h1 id="pt">Верстак</h1><div class="acts">
|
||||
<button onclick="showCNM()">+ Заметка</button>
|
||||
<button onclick="showCM()">+ Дело</button>
|
||||
<button onclick="document.getElementById('fi').click()">+ Файл</button>
|
||||
<input type="file" id="fi" style="display:none" onchange="handleFA(event)">
|
||||
</div></div>
|
||||
<div id="tabs"><div class="t a" onclick="switchTab(this,'ov')">Обзор</div><div class="t" onclick="switchTab(this,'nt')">Заметки</div><div class="t" onclick="switchTab(this,'fl')">Файлы</div></div>
|
||||
<div id="srch"><input id="si" placeholder="Поиск по делам и заметкам..." autocomplete="off" oninput="handleSR(this.value)"></div>
|
||||
<div id="sr-res"></div>
|
||||
<div id="cnt"><div class="empty">Выберите дело или создайте новое</div></div>
|
||||
</div>
|
||||
|
||||
<div id="ed">
|
||||
<div id="ed-hdr"><span id="et">Редактор</span><div><button onclick="closeED()">✕</button> <button class="p" onclick="saveNT()">💾 Сохранить</button></div></div>
|
||||
<textarea id="ed-ta" placeholder="Пишите в Markdown..."></textarea>
|
||||
<div id="ed-ftr"></div>
|
||||
</div>
|
||||
|
||||
<div class="mo" id="cm">
|
||||
<div class="md"><h3>Новое дело</h3>
|
||||
<label>Тип</label><select id="ct"><option value="case">◇ Дело</option><option value="folder">▸ Папка</option><option value="space">◎ Пространство</option><option value="recipe">◈ Рецепт</option></select>
|
||||
<label>Название</label><input id="cn" placeholder="Название..." autofocus>
|
||||
<div class="ma"><button onclick="closeCM()">Отмена</button><button class="p" onclick="submitCM()">Создать</button></div></div>
|
||||
</div>
|
||||
|
||||
<div class="mo" id="cnm">
|
||||
<div class="md"><h3>Новая заметка</h3>
|
||||
<label>Название</label><input id="cnn" placeholder="Название заметки..." autofocus>
|
||||
<div class="ma"><button onclick="closeCNM()">Отмена</button><button class="p" onclick="submitCNM()">Создать</button></div></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API='';
|
||||
let cur='',editId='',tab='ov';
|
||||
|
||||
async function api(p,opt){
|
||||
const r=await fetch(API+p,{headers:{'Content-Type':'application/json'},...opt});
|
||||
if(!r.ok)throw new Error(r.status);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
// tree
|
||||
async function loadTree(){
|
||||
try{
|
||||
const items=await api('/api/nodes');
|
||||
const t=document.getElementById('tree');
|
||||
if(!items.length){t.innerHTML='<div class="empty" style="padding:30px 14px;font-size:13px">Пусто. Создайте первое дело.</div>';return}
|
||||
t.innerHTML=renderL(items,'');
|
||||
}catch(e){document.getElementById('tree').innerHTML='<div class="empty" style="padding:30px 14px">Ошибка загрузки</div>'}
|
||||
}
|
||||
|
||||
function renderL(items,pid){
|
||||
let h='<ul'+(pid?' class="tc"':'')+'>';
|
||||
for(const n of items){
|
||||
h+='<li><div class="ti'+(cur===n.id?' sel':'')+'" data-id="'+n.id+'" onclick="selN(\''+n.id+'\')">';
|
||||
h+='<span class="ar">'+('▸')+'</span><span class="ic">'+nic(n.type)+'</span>';
|
||||
h+='<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(n.title)+'</span></div></li>';
|
||||
}
|
||||
h+='</ul>';return h;
|
||||
}
|
||||
|
||||
function nic(t){const i={case:'◇',folder:'▸',space:'◎',note:'📝',document:'📄',file:'📎',recipe:'◈',action:'⚡',secret:'🔒',worklog:'⏱',link:'🔗'};return i[t]||'◇'}
|
||||
function esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
||||
|
||||
async function selN(id){
|
||||
cur=id;
|
||||
document.querySelectorAll('.ti').forEach(e=>e.classList.toggle('sel',e.dataset.id===id));
|
||||
try{
|
||||
const d=await api('/api/nodes/'+id);
|
||||
document.getElementById('pt').textContent=d.node.title;
|
||||
renderOV(d);
|
||||
}catch(e){document.getElementById('cnt').innerHTML='<div class="empty">Ошибка</div>'}
|
||||
}
|
||||
|
||||
function renderOV(d){
|
||||
const ch=d.children||[],fl=d.files||[];
|
||||
let h='<div class="bc">'+(d.node.type||'Дело')+'</div>';
|
||||
if(ch.length||fl.length){
|
||||
h+='<div class="cg">';
|
||||
for(const c of ch){
|
||||
h+='<div class="card" onclick="selN(\''+c.id+'\')"><div class="ct">'+esc(c.title)+'</div><div class="cy">'+c.type+'</div></div>';
|
||||
}
|
||||
for(const f of fl){
|
||||
h+='<div class="card"><div class="ct">📎 '+esc(f.filename)+'</div><div class="cy">'+(f.mime||'file')+'</div><div class="cm">'+fsz(f.size)+'</div></div>';
|
||||
}
|
||||
h+='</div>';
|
||||
} else {
|
||||
h+='<div class="empty" style="margin-top:40px">Пусто. Добавьте заметку или файл.</div>';
|
||||
}
|
||||
document.getElementById('cnt').innerHTML=h;
|
||||
}
|
||||
|
||||
function fsz(b){if(b<1024)return b+' B';if(b<1048576)return(b/1024).toFixed(1)+' KB';return(b/1048576).toFixed(1)+' MB'}
|
||||
|
||||
function switchTab(el,t){
|
||||
document.querySelectorAll('#tabs .t').forEach(e=>e.classList.remove('a'));
|
||||
el.classList.add('a');tab=t;
|
||||
if(t==='ov'&&cur)selN(cur);else if(t==='nt')loadNT();else if(t==='fl')loadFL();
|
||||
}
|
||||
|
||||
async function loadNT(){
|
||||
if(!cur){document.getElementById('cnt').innerHTML='<div class="empty">Выберите делó</div>';return}
|
||||
const d=await api('/api/nodes/'+cur);
|
||||
const ns=(d.children||[]).filter(c=>c.type==='note');
|
||||
if(!ns.length){document.getElementById('cnt').innerHTML='<div class="empty">Нет заметок. Создайте первую.</div>';return}
|
||||
let h='<div class="cg">';
|
||||
for(const n of ns)h+='<div class="card" onclick="openNT(\''+n.id+'\')"><div class="ct">📝 '+esc(n.title)+'</div><div class="cy">заметка</div></div>';
|
||||
h+='</div>';document.getElementById('cnt').innerHTML=h;
|
||||
}
|
||||
|
||||
async function loadFL(){
|
||||
if(!cur){document.getElementById('cnt').innerHTML='<div class="empty">Выберите дело</div>';return}
|
||||
const d=await api('/api/nodes/'+cur);
|
||||
const fl=d.files||[];
|
||||
if(!fl.length){document.getElementById('cnt').innerHTML='<div class="empty">Нет файлов</div>';return}
|
||||
let h='<div class="cg">';
|
||||
for(const f of fl)h+='<div class="card"><div class="ct">📎 '+esc(f.filename)+'</div><div class="cy">'+(f.mime||'file')+'</div><div class="cm">'+fsz(f.size)+'</div></div>';
|
||||
h+='</div>';document.getElementById('cnt').innerHTML=h;
|
||||
}
|
||||
|
||||
// editor
|
||||
async function openNT(id){
|
||||
editId=id;
|
||||
try{const d=await api('/api/notes/'+id);document.getElementById('et').textContent='Заметка';document.getElementById('ed-ta').value=d.content||'';document.getElementById('ed').style.display='flex'}
|
||||
catch(e){alert('Ошибка: '+e.message)}
|
||||
}
|
||||
function closeED(){document.getElementById('ed').style.display='none';editId=''}
|
||||
async function saveNT(){
|
||||
if(!editId)return;
|
||||
try{await api('/api/notes/'+editId,{method:'PUT',body:JSON.stringify({content:document.getElementById('ed-ta').value})});closeED();if(cur)selN(cur)}
|
||||
catch(e){alert('Ошибка: '+e.message)}
|
||||
}
|
||||
|
||||
// modals
|
||||
function showCM(){document.getElementById('cm').classList.add('on');document.getElementById('cn').focus()}
|
||||
function closeCM(){document.getElementById('cm').classList.remove('on');document.getElementById('cn').value=''}
|
||||
async function submitCM(){
|
||||
const type=document.getElementById('ct').value,title=document.getElementById('cn').value.trim();
|
||||
if(!title)return;
|
||||
try{await api('/api/nodes',{method:'POST',body:JSON.stringify({parent_id:cur,type,title})});closeCM();loadTree();if(cur)selN(cur)}
|
||||
catch(e){alert('Ошибка: '+e.message)}
|
||||
}
|
||||
|
||||
function showCNM(){document.getElementById('cnm').classList.add('on');document.getElementById('cnn').focus()}
|
||||
function closeCNM(){document.getElementById('cnm').classList.remove('on');document.getElementById('cnn').value=''}
|
||||
async function submitCNM(){
|
||||
const title=document.getElementById('cnn').value.trim();
|
||||
if(!title)return;
|
||||
try{const n=await api('/api/notes/'+(cur||''),{method:'POST',body:JSON.stringify({parent_id:cur,title})});closeCNM();loadTree();selN(n.id);switchTab(document.querySelectorAll('#tabs .t')[1],'nt')}
|
||||
catch(e){alert('Ошибка: '+e.message)}
|
||||
}
|
||||
|
||||
function handleFA(ev){const f=ev.target.files[0];if(!f)return;alert('MVP: загрузка файлов будет в следующей версии. Файл: '+f.name);ev.target.value=''}
|
||||
|
||||
// search
|
||||
let sT=null;
|
||||
async function handleSR(q){
|
||||
clearTimeout(sT);
|
||||
const b=document.getElementById('sr-res');
|
||||
if(!q||q.length<2){b.style.display='none';return}
|
||||
sT=setTimeout(async()=>{
|
||||
try{
|
||||
// For MVP, fetch all roots and filter locally
|
||||
const items=await api('/api/nodes');
|
||||
const hits=items.filter(n=>n.title.toLowerCase().includes(q.toLowerCase()));
|
||||
if(!hits.length){b.style.display='none';return}
|
||||
let h='';for(const r of hits){h+='<div class="sri" onclick="selN(\''+r.id+'\');b.style.display=\'none\'"><div class="srt">'+r.type+'</div><div>'+esc(r.title)+'</div></div>'}
|
||||
b.innerHTML=h;b.style.display='block';
|
||||
}catch(e){b.style.display='none'}
|
||||
},200);
|
||||
}
|
||||
|
||||
// keyboard shortcuts
|
||||
document.addEventListener('keydown',e=>{
|
||||
if(e.key==='Escape'){closeED();closeCM();closeCNM();document.getElementById('sr-res').style.display='none'}
|
||||
if(e.key==='n'&&(e.ctrlKey||e.metaKey)){e.preventDefault();showCM()}
|
||||
});
|
||||
|
||||
loadTree();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user