From 9bc5de4c56e7db746ca0bd1c71b575c4178a2f33 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Costa Date: Wed, 4 Nov 2026 09:41:46 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20aplica=C3=A7=C3=A3o=20inicial=20?= =?UTF-8?q?=E2=80=94=20gerenciador=20de=20notas=20web=20estilo=20Mousepad?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend Go: API REST, auth por token (Bearer + cookie HttpOnly), storage em disco com revisões content-addressed (estilo Git: hash, autor, data, diff, restauração) - Editor txt/md/html com preview, tags #tag, categorias, painel de histórico - Preferências: números de linha, marcador de coluna (padrão 80), quebra de linha, fonte/tamanho, 4 temas e colorização de sintaxe (overlay no textarea) - Frontend vanilla embutido via go:embed; Dockerfile multi-stage + docker-compose --- .dockerignore | 10 + .env.example | 5 + .gitignore | 4 + Dockerfile | 33 ++ README.md | 168 ++++++ cmd/notas/main.go | 86 +++ docker-compose.yml | 20 + go.mod | 8 + go.sum | 4 + internal/api/api_test.go | 271 +++++++++ internal/api/notes.go | 165 ++++++ internal/api/render.go | 35 ++ internal/api/revisions.go | 112 ++++ internal/api/server.go | 211 +++++++ internal/diffx/diffx.go | 56 ++ internal/diffx/diffx_test.go | 44 ++ internal/store/store.go | 718 ++++++++++++++++++++++ internal/store/store_test.go | 241 ++++++++ web/css/app.css | 704 ++++++++++++++++++++++ web/embed.go | 9 + web/index.html | 254 ++++++++ web/js/app.js | 1081 ++++++++++++++++++++++++++++++++++ 22 files changed, 4239 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 cmd/notas/main.go create mode 100644 docker-compose.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/api/api_test.go create mode 100644 internal/api/notes.go create mode 100644 internal/api/render.go create mode 100644 internal/api/revisions.go create mode 100644 internal/api/server.go create mode 100644 internal/diffx/diffx.go create mode 100644 internal/diffx/diffx_test.go create mode 100644 internal/store/store.go create mode 100644 internal/store/store_test.go create mode 100644 web/css/app.css create mode 100644 web/embed.go create mode 100644 web/index.html create mode 100644 web/js/app.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d13fde0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.gitignore +data/ +docs/ +.env +.env.example +*.md +Dockerfile +docker-compose.yml +notas diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..307ff52 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Copie para .env e defina o seu token: cp .env.example .env +# Gere um token forte: openssl rand -hex 24 +NOTES_TOKEN=troque-este-token +# Porta do host (opcional): +# NOTAS_PORT=9090 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6343c72 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/notas +/data/ +.env +*.tmp diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..40030bf --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +# syntax=docker/dockerfile:1 +# --------------------------------------------------------------------------- +# Estágio 1: compila o binário estático (sem CGO) com o frontend embutido. +# --------------------------------------------------------------------------- +FROM golang:1.24-alpine AS build + +WORKDIR /src + +# Dependências primeiro, para aproveitar o cache de camadas. +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/notas ./cmd/notas \ + && mkdir -p /out/data \ + && chown -R 65534:65534 /out/data + +# --------------------------------------------------------------------------- +# Estágio 2: imagem final mínima (scratch), apenas o binário + diretório de dados. +# --------------------------------------------------------------------------- +FROM scratch + +COPY --from=build /out/notas /notas +COPY --from=build --chown=65534:65534 /out/data /data + +USER 65534:65534 +EXPOSE 8080 +ENV NOTES_DATA_DIR=/data \ + NOTES_ADDR=:8080 + +VOLUME /data + +ENTRYPOINT ["/notas"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..f13d372 --- /dev/null +++ b/README.md @@ -0,0 +1,168 @@ +# Notas — editor de notas web estilo Mousepad + +Aplicação web completa para gerenciamento de notas inspirada no visual sóbrio e minimalista do +**Mousepad** (editor de texto do XFCE): barra de menus superior, barra de ferramentas, área de +texto limpa e barra de status na parte inferior. + +Backend único em **Go** (binário estático de ~11 MB com o frontend já embutido), armazenamento em +disco no estilo **Git** (blobs endereçados por SHA-256 + histórico de revisões com diff e +restauração) e autenticação por **token** (Bearer para API / cookie HttpOnly para o navegador). + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ ☰ Notas Arquivo Editar Exibir Ajuda │ ← barra de menus +├──────────────────────────────────────────────────────────────────────┤ +│ +Nova 💾Salvar ⬇Baixar │ B I S H1 H2 • 1 ❝ 🔗 { } │ Formato ▾ │ ← barra de ferramentas +│ [ Editar | Preview ] 🕘Versões │ +├──────────────┬──────────────────────────────────────┬──────────────── +│ Buscar… │ Título da nota categoria │ HISTÓRICO DE │ +│ CATEGORIA ▾ ├──────────────────────────────────────┤ │ REVISÕES │ +│ TAGS │ # Placas │ │ hash · autor │ +│ #linux 2 │ **nvme** em `/dev/nvme0` │ │ mensagem · data│ +│ NOTAS │ - controladora #linux │ ├────────────────┤ +│ nota1 .md │ … │ │ diff unificado │ +│ nota2 .txt │ │ │ +restaurar │ +├──────────────┴──────────────────────────────────────┴────────────────┤ +│ nota.md .md Ln 4, Col 12 128 caracteres · 20 palavras ●● │ ← barra de status +└──────────────────────────────────────────────────────────────────────┘ +``` + +## Funcionalidades + +- **3 formatos por nota**: Texto puro (`.txt`), Markdown (`.md`) e HTML (`.html`), alternáveis no + editor; a nota é baixada com a extensão correspondente. +- **Barra de ferramentas de formatação**: negrito, itálico, tachado, títulos H1/H2, listas, + citação, link, código inline e bloco de código — inserindo sintaxe Markdown **ou** HTML conforme + o formato ativo (desabilitada em texto puro). +- **Modo Edição ↔ Preview**: Markdown é renderizado no servidor (goldmark, GFM, HTML cru escapado); + HTML é pré-visualizado em ` + + + + + + + + + + + + + + + + diff --git a/web/js/app.js b/web/js/app.js new file mode 100644 index 0000000..cf0e5cf --- /dev/null +++ b/web/js/app.js @@ -0,0 +1,1081 @@ +/* Notas — frontend estilo Mousepad (vanilla JS, sem dependências). */ +'use strict'; +(() => { + const $ = (s) => document.querySelector(s); + + const E = { + loginOverlay: $('#login-overlay'), loginForm: $('#login-form'), loginToken: $('#login-token'), + loginError: $('#login-error'), + modalOverlay: $('#modal-overlay'), modalTitle: $('#modal-title'), modalBody: $('#modal-body'), + modalOk: $('#modal-ok'), modalCancel: $('#modal-cancel'), + toolbar: $('#toolbar'), fmtTools: $('#fmt-tools'), formatSelect: $('#format-select'), + modeToggle: $('#mode-toggle'), + btnNew: $('#btn-new'), btnSave: $('#btn-save'), btnExport: $('#btn-export'), + btnVersions: $('#btn-versions'), btnSidebar: $('#btn-sidebar'), btnCloseVersions: $('#btn-close-versions'), + searchInput: $('#search-input'), categorySelect: $('#category-select'), tagCloud: $('#tag-cloud'), + notesList: $('#notes-list'), notesCount: $('#notes-count'), + noteTitle: $('#note-title'), noteCategory: $('#note-category'), + editor: $('#editor'), editorWrap: $('#editor-wrap'), gutter: $('#gutter'), + gutterInner: $('#gutter-inner'), hl: $('#hl'), colMarker: $('#col-marker'), + preview: $('#preview'), previewFrame: $('#preview-frame'), + versionsPanel: $('#versions-panel'), revisionsList: $('#revisions-list'), + versionDetail: $('#version-detail'), versionInfo: $('#version-info'), versionDiff: $('#version-diff'), + btnRestore: $('#btn-restore'), revMessage: $('#rev-message'), authorInput: $('#author-input'), + stFile: $('#st-file'), stFormat: $('#st-format'), stPos: $('#st-pos'), + stStats: $('#st-stats'), stSaved: $('#st-saved'), + toast: $('#toast'), + }; + + const EXT = { txt: '.txt', md: '.md', html: '.html' }; + + const state = { + note: null, // metadados da nota aberta (null = rascunho não salvo) + content: '', + dirty: false, + mode: 'edit', // 'edit' | 'preview' + filter: { category: '*', tag: '', q: '' }, + notes: [], + selectedRev: null, + lastSaved: null, + }; + + /* ------------------------------------------------------------ util */ + + class ApiError extends Error { + constructor(msg, status) { super(msg); this.name = 'ApiError'; this.status = status; } + } + + async function api(method, path, body) { + const opt = { method, headers: {} }; + if (body !== undefined) { + opt.headers['Content-Type'] = 'application/json'; + opt.body = JSON.stringify(body); + } + const resp = await fetch(path, opt); + if (resp.status === 401) { showLogin(); throw new ApiError('não autenticado', 401); } + if (resp.status === 204) return null; + const data = await resp.json().catch(() => ({})); + if (!resp.ok) throw new ApiError(data.error || resp.statusText, resp.status); + return data; + } + + let toastTimer = 0; + function toast(msg) { + E.toast.textContent = msg; + E.toast.classList.remove('hidden'); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => E.toast.classList.add('hidden'), 2800); + } + + function relTime(iso) { + const t = new Date(iso).getTime(); + const d = Math.floor((Date.now() - t) / 1000); + if (d < 45) return 'agora mesmo'; + if (d < 3600) return `há ${Math.floor(d / 60)} min`; + if (d < 86400) return `há ${Math.floor(d / 3600)} h`; + if (d < 7 * 86400) return `há ${Math.floor(d / 86400)} d`; + return new Date(t).toLocaleDateString('pt-BR'); + } + + function esc(s) { + return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + } + + const currentFormat = () => E.formatSelect.value; + const currentTitle = () => { + const t = E.noteTitle.value.trim(); + if (t) return t; + const first = (E.editor.value.trim().split('\n')[0] || '').slice(0, 42); + return first || 'Sem título'; + }; + + function confirmDiscard() { + if (!state.dirty) return Promise.resolve(true); + return modal({ + title: 'Descartar alterações?', + bodyHTML: 'As alterações não salvas desta nota serão perdidas.', + withCancel: true, okLabel: 'Descartar', + }); + } + + function modal({ title, bodyHTML, okLabel = 'OK', withCancel = false }) { + return new Promise((resolve) => { + E.modalTitle.textContent = title; + E.modalBody.innerHTML = bodyHTML; // conteúdo é sempre texto autor do app + E.modalOk.textContent = okLabel; + E.modalCancel.classList.toggle('hidden', !withCancel); + E.modalOverlay.classList.remove('hidden'); + const done = (ok) => { + E.modalOverlay.classList.add('hidden'); + E.modalOk.removeEventListener('click', okClick); + E.modalCancel.removeEventListener('click', cancelClick); + resolve(ok); + }; + const okClick = () => done(true); + const cancelClick = () => done(false); + E.modalOk.addEventListener('click', okClick); + E.modalCancel.addEventListener('click', cancelClick); + }); + } + + /* ------------------------------------------------------------ login */ + + function showLogin() { + E.loginOverlay.classList.remove('hidden'); + E.loginToken.focus(); + } + function hideLogin() { + E.loginOverlay.classList.add('hidden'); + E.loginError.classList.add('hidden'); + E.loginToken.value = ''; + } + + E.loginForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const token = E.loginToken.value.trim(); + if (!token) return; + const resp = await fetch('/api/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }).catch(() => null); + if (resp && resp.ok) { + hideLogin(); + await boot(); + } else { + E.loginError.textContent = resp ? 'Token inválido. Tente novamente.' : 'Servidor indisponível.'; + E.loginError.classList.remove('hidden'); + } + }); + + /* ------------------------------------------------------------ dados */ + + async function loadNotes() { + const f = state.filter; + const q = new URLSearchParams(); + if (f.category !== '*') q.set('category', f.category); + if (f.tag) q.set('tag', f.tag); + if (f.q) q.set('q', f.q); + const suffix = q.toString() ? '?' + q.toString() : ''; + state.notes = await api('GET', '/api/notes' + suffix); + renderNotesList(); + } + + async function loadAggregates() { + const [tags, cats] = await Promise.all([api('GET', '/api/tags'), api('GET', '/api/categories')]); + // tag cloud + if (!tags.length) { + E.tagCloud.innerHTML = 'nenhuma tag ainda'; + } else { + E.tagCloud.textContent = ''; + for (const t of tags) { + const b = document.createElement('button'); + b.type = 'button'; + b.className = 'tag-chip' + (state.filter.tag === t.tag ? ' active' : ''); + b.dataset.tag = t.tag; + b.textContent = '#' + t.tag; + const n = document.createElement('span'); + n.className = 'tag-n'; + n.textContent = String(t.count); + b.appendChild(n); + b.addEventListener('click', () => { + state.filter.tag = state.filter.tag === t.tag ? '' : t.tag; + loadAggregates().catch(() => {}); + loadNotes().catch(() => {}); + }); + E.tagCloud.appendChild(b); + } + } + // categorias no seletor + const want = state.filter.category; + E.categorySelect.textContent = ''; + const mk = (value, label) => { + const o = document.createElement('option'); + o.value = value; o.textContent = label; + E.categorySelect.appendChild(o); + }; + mk('*', 'Todas as categorias'); + mk('', 'Sem categoria'); + for (const c of cats) if (c.category) mk(c.category, c.category + ' (' + c.count + ')'); + E.categorySelect.value = [...E.categorySelect.options].some((o) => o.value === want) ? want : '*'; + } + + function renderNotesList() { + E.notesList.textContent = ''; + E.notesCount.textContent = String(state.notes.length); + for (const n of state.notes) { + const li = document.createElement('li'); + li.className = n.id === (state.note && state.note.id) ? 'active' : ''; + const title = document.createElement('div'); + title.className = 'n-title'; + title.textContent = n.title || 'Sem título'; + const ext = document.createElement('span'); + ext.className = 'n-ext'; + ext.textContent = ' ' + EXT[n.format]; + title.appendChild(ext); + const sub = document.createElement('div'); + sub.className = 'n-sub'; + const bits = [relTime(n.updated_at)]; + if (n.category) bits.push(n.category); + if (n.tags && n.tags.length) bits.push(n.tags.map((t) => '#' + t).join(' ')); + sub.textContent = bits.join(' · '); + li.appendChild(title); + li.appendChild(sub); + li.addEventListener('click', () => openNote(n.id)); + E.notesList.appendChild(li); + } + if (!state.notes.length) { + const li = document.createElement('li'); + li.textContent = 'nenhuma nota encontrada'; + li.classList.add('muted'); + E.notesList.appendChild(li); + } + } + + async function openNote(id) { + if (!(await confirmDiscard())) return; + const data = await api('GET', '/api/notes/' + id); + state.note = data; + state.content = data.content; + state.dirty = false; + state.lastSaved = data.updated_at; + fillEditor(); + if (!E.versionsPanel.classList.contains('hidden')) loadRevisions().catch(() => {}); + renderNotesList(); + updateStatus(); + } + + function fillEditor() { + const n = state.note; + E.noteTitle.value = n ? (n.title || '') : ''; + E.noteCategory.value = n ? (n.category || '') : ''; + E.formatSelect.value = n ? n.format : 'md'; + E.editor.value = state.content; + updateToolbar(); + updateStatus(); + renderEditorVisual(); + refreshPreviewIfOpen(); + } + + async function newDraft(force = false) { + if (!force && !(await confirmDiscard())) return; + state.note = null; + state.content = ''; + state.dirty = false; + state.lastSaved = null; + state.selectedRev = null; + E.revisionsList.textContent = ''; + E.versionDetail.classList.add('hidden'); + fillEditor(); + E.editor.focus(); + renderNotesList(); + } + + async function save() { + const body = { + title: currentTitle(), + format: currentFormat(), + category: E.noteCategory.value.trim(), + content: E.editor.value, + author: E.authorInput.value.trim(), + message: E.revMessage.value.trim(), + }; + let data; + try { + if (state.note) data = await api('PUT', '/api/notes/' + state.note.id, body); + else data = await api('POST', '/api/notes', body); + } catch (err) { + toast('Falha ao salvar: ' + err.message); + return; + } + state.note = data; + state.content = data.content; + state.dirty = false; + state.lastSaved = new Date().toISOString(); + E.revMessage.value = ''; + renderNotesList(); + loadNotes().catch(() => {}); + loadAggregates().catch(() => {}); + if (!E.versionsPanel.classList.contains('hidden')) loadRevisions().catch(() => {}); + updateStatus(); + toast('Salvo — revisão ' + data.hash.slice(0, 8)); + } + + async function deleteNote() { + if (!state.note) { toast('Nenhuma nota aberta.'); return; } + const ok = await modal({ + title: 'Excluir nota?', + bodyHTML: `A nota ${esc(state.note.title || 'Sem título')} e todo o seu histórico serão apagados.`, + withCancel: true, okLabel: 'Excluir', + }); + if (!ok) return; + try { + await api('DELETE', '/api/notes/' + state.note.id); + toast('Nota excluída.'); + await newDraft(true); + loadNotes().catch(() => {}); + loadAggregates().catch(() => {}); + } catch (err) { + toast('Falha ao excluir: ' + err.message); + } + } + + function exportNote() { + if (!state.note) { toast('Salve a nota antes de baixar.'); return; } + if (state.dirty) toast('Atenção: o arquivo baixado é a última versão salva.'); + window.location.assign('/api/notes/' + state.note.id + '/raw'); + } + + /* ------------------------------------------------------- preferências */ + + const PREFS_KEY = 'notas_prefs'; + const PREFS_DEFAULT = { gutter: true, wrap: true, colmark: true, col: 80, hl: true, font: 'mono', size: 14, theme: 'claro' }; + const THEMES = ['claro', 'escuro', 'solar-claro', 'solar-escuro']; + const FONT_STACKS = { + mono: '"DejaVu Sans Mono", "Ubuntu Mono", "Cascadia Code", Consolas, monospace', + ubuntu: '"Ubuntu Mono", "DejaVu Sans Mono", monospace', + cascadia: '"Cascadia Code", "DejaVu Sans Mono", monospace', + liberation: '"Liberation Mono", "DejaVu Sans Mono", monospace', + courier: '"Courier New", "DejaVu Sans Mono", monospace', + system: 'ui-monospace, "Cascadia Mono", Consolas, "DejaVu Sans Mono", monospace', + }; + + function loadPrefs() { + let raw = {}; + try { raw = JSON.parse(localStorage.getItem(PREFS_KEY) || '{}'); } catch { /* usa padrões */ } + const p = { ...PREFS_DEFAULT }; + for (const k of Object.keys(PREFS_DEFAULT)) { + if (typeof raw[k] === typeof PREFS_DEFAULT[k]) p[k] = raw[k]; + } + p.col = Math.min(320, Math.max(20, Math.round(p.col) || 80)); + p.size = Math.min(32, Math.max(10, Math.round(p.size) || 14)); + if (!FONT_STACKS[p.font]) p.font = 'mono'; + if (!THEMES.includes(p.theme)) p.theme = 'claro'; + return p; + } + + let prefs = loadPrefs(); + function persistPrefs() { localStorage.setItem(PREFS_KEY, JSON.stringify(prefs)); } + + function applyPrefs() { + const root = document.documentElement; + root.style.setProperty('--editor-font', FONT_STACKS[prefs.font]); + root.style.setProperty('--editor-size', prefs.size + 'px'); + root.style.setProperty('--line-h', Math.round(prefs.size * 1.55) + 'px'); + root.dataset.theme = prefs.theme; + document.body.classList.toggle('no-gutter', !prefs.gutter); + if (prefs.wrap) E.editor.removeAttribute('wrap'); + else E.editor.setAttribute('wrap', 'off'); + E.hl.classList.toggle('nowrap', !prefs.wrap); + lastGutterCount = -1; // força rebuild do gutter (altura/fonte mudaram) + renderEditorVisual(); + measureCh(); + renderEditorVisual(); // segunda passada com chW correto p/ marcador + } + + /* --------------------------------------------------- colorização (hl) */ + + const HL_MAX = 200 * 1024; // acima disso, sem tokens (mantém o editor ágil) + + function sp(cls, text) { + const s = document.createElement('span'); + if (cls) s.className = cls; + s.textContent = text; + return s; + } + function lineEl(children) { + const d = document.createElement('div'); + d.className = 'hl-line'; + if (!children.length) children = [sp(null, '\u200b')]; + for (const c of children) d.appendChild(c); + return d; + } + + const TAG_URL_RE = /(#[\p{L}][\p{L}\p{N}_-]*)|(https?:\/\/[^\s<>"')\]]+)|(&[\w#]+;)/gu; + + // Segmentos comuns: #tags, URLs e entidades HTML. + function tagUrlEnt(seg) { + const out = []; + let last = 0, m; + TAG_URL_RE.lastIndex = 0; + while ((m = TAG_URL_RE.exec(seg))) { + if (m.index > last) out.push(sp(null, seg.slice(last, m.index))); + out.push(sp(m[1] ? 'tk-tag' : m[2] ? 'tk-url' : 'tk-ent', m[0])); + last = m.index + m[0].length; + } + if (last < seg.length) out.push(sp(null, seg.slice(last))); + return out; + } + + function mdInline(line) { + const re = /(`[^`\n]*`?)|(\*\*[^*\n]+\*\*|__[^_\n]+__)|(\*[^*\n]+\*|_[^_\n]+_)|(~~[^~\n]+~~)|(\[[^\]\n]*\]\([^)\n]*\))|(#[\p{L}][\p{L}\p{N}_-]*)|(https?:\/\/[^\s<>"')\]]+)|(<[^>\n]*>)/gu; + const out = []; + let last = 0, m; + while ((m = re.exec(line))) { + if (m.index > last) out.push(...tagUrlEnt(line.slice(last, m.index))); + const t = m[0]; + if (m[1]) out.push(sp('tk-code', t)); + else if (m[2]) out.push(sp('tk-strong', t)); + else if (m[3]) out.push(sp('tk-em', t)); + else if (m[4]) out.push(sp('tk-strike', t)); + else if (m[5]) { + const mm = /^\[([^\]]*)\]\(([^)]*)\)$/.exec(t); + out.push(sp('tk-punc', '['), sp('tk-link', mm[1]), sp('tk-punc', ']('), sp('tk-url', mm[2]), sp('tk-punc', ')')); + } else if (m[6]) out.push(sp('tk-tag', t)); + else if (m[7]) out.push(sp('tk-url', t)); + else out.push(sp('tk-html', t)); + last = m.index + t.length; + } + if (last < line.length) out.push(...tagUrlEnt(line.slice(last))); + return out; + } + + function buildMdLines(lines) { + const out = []; + let fence = null; + for (const line of lines) { + let children; + const f = /^\s*(```|~~~)/.exec(line); + if (fence) { + if (new RegExp('^\\s*' + fence).test(line)) { children = [sp('tk-fence', line)]; fence = null; } + else children = [sp('tk-fline', line)]; + } else if (f) { fence = f[1]; children = [sp('tk-fence', line)]; } + else if (/^#{1,6}\s/.test(line)) children = [sp('tk-h', line)]; + else if (/^\s*>/.test(line)) children = [sp('tk-quote', line)]; + else { + const lm = /^(\s*)([-*+]\s|\d+\.\s)(.*)$/.exec(line); + children = lm ? [sp(null, lm[1]), sp('tk-mark', lm[2]), ...mdInline(lm[3])] : mdInline(line); + } + out.push(lineEl(children)); + } + return out; + } + + function tagTokens(t) { + const out = []; + const open = /^<\/?[a-zA-Z!]/.test(t) ? (t[1] === '/' ? 2 : 1) : 0; + out.push(sp('tk-punc', t.slice(0, open))); + let i = open, nameDone = false; + while (i < t.length) { + const rest = t.slice(i); + let m; + if ((m = /^\s+/.exec(rest))) out.push(sp(null, m[0])); + else if ((m = /^"[^"]*"|^'[^']*'/.exec(rest))) out.push(sp('tk-str', m[0])); + else if ((m = /^[a-zA-Z!][\w:.-]*/.exec(rest))) { out.push(sp(nameDone ? 'tk-attr' : 'tk-html', m[0])); nameDone = true; } + else if ((m = /^[=/>]+/.exec(rest))) out.push(sp('tk-punc', m[0])); + else { out.push(sp(null, rest[0])); i++; continue; } + i += m[0].length; + } + return out; + } + + function buildHtmlLines(lines) { + const out = []; + let inComment = false; + for (const line of lines) { + const children = []; + let i = 0; + while (i < line.length) { + if (inComment) { + const j = line.indexOf('-->', i); + if (j < 0) { children.push(sp('tk-comment', line.slice(i))); i = line.length; } + else { children.push(sp('tk-comment', line.slice(i, j + 3))); i = j + 3; inComment = false; } + continue; + } + const lt = line.indexOf('<', i); + if (lt < 0) { children.push(...tagUrlEnt(line.slice(i))); break; } + if (lt > i) children.push(...tagUrlEnt(line.slice(i, lt))); + if (line.startsWith('', lt); + if (j < 0) { children.push(sp('tk-comment', line.slice(lt))); inComment = true; break; } + children.push(sp('tk-comment', line.slice(lt, j + 3))); i = j + 3; continue; + } + const gt = line.indexOf('>', lt); + if (gt < 0) { children.push(...tagUrlEnt(line.slice(lt))); break; } + children.push(...tagTokens(line.slice(lt, gt + 1))); + i = gt + 1; + } + out.push(lineEl(children)); + } + return out; + } + + /* ------------------------------------------------- render do overlay */ + + let lastGutterCount = -1; + let chW = 8; + let rafPending = false; + + function renderEditorVisual() { + const v = E.editor.value; + const lines = v.split('\n'); + const plain = !prefs.hl || v.length > HL_MAX; + let rowEls; + if (plain) rowEls = lines.map((l) => lineEl([sp(null, l)])); + else if (currentFormat() === 'md') rowEls = buildMdLines(lines); + else if (currentFormat() === 'html') rowEls = buildHtmlLines(lines); + else rowEls = lines.map((l) => lineEl(tagUrlEnt(l))); + const frag = document.createDocumentFragment(); + for (const r of rowEls) frag.appendChild(r); + E.hl.replaceChildren(frag); + if (lines.length !== lastGutterCount) { + const gf = document.createDocumentFragment(); + for (let i = 1; i <= lines.length; i++) { + const d = document.createElement('div'); + d.className = 'ln'; + d.textContent = String(i); + gf.appendChild(d); + } + E.gutterInner.replaceChildren(gf); + lastGutterCount = lines.length; + } + if (prefs.wrap) { + const rows = E.gutterInner.children; + for (let i = 0; i < rowEls.length; i++) rows[i].style.height = E.hl.children[i].offsetHeight + 'px'; + } + updateMarker(); + } + + function scheduleRender() { + if (rafPending) return; + rafPending = true; + requestAnimationFrame(() => { rafPending = false; renderEditorVisual(); }); + } + + function measureCh() { + const s = document.createElement('span'); + s.className = 'hl-line'; + s.textContent = '0'.repeat(64); + E.hl.appendChild(s); + const w = s.getBoundingClientRect().width; + s.remove(); + if (w > 0) chW = w / 64; + } + + function updateMarker() { + const show = prefs.colmark && !prefs.wrap && state.mode === 'edit'; + if (!show) { E.colMarker.classList.add('hidden'); return; } + const ta = E.editor; + const padX = parseFloat(getComputedStyle(ta).paddingLeft) || 16; + const left = padX + prefs.col * chW - ta.scrollLeft; + const visible = left >= 0 && left <= ta.clientWidth; + E.colMarker.classList.toggle('hidden', !visible); + if (visible) E.colMarker.style.left = left + 'px'; + } + + E.editor.addEventListener('scroll', () => { + E.hl.scrollTop = E.editor.scrollTop; + E.hl.scrollLeft = E.editor.scrollLeft; + E.gutter.scrollTop = E.editor.scrollTop; + updateMarker(); + }); + window.addEventListener('resize', () => { updateMarker(); scheduleRender(); }); + + /* --------------------------------------------------- diálogo preferências */ + + const PF = { + overlay: $('#prefs-overlay'), form: $('#prefs-form'), gutter: $('#pf-gutter'), + wrap: $('#pf-wrap'), colmark: $('#pf-colmark'), col: $('#pf-col'), hl: $('#pf-hl'), + font: $('#pf-font'), size: $('#pf-size'), theme: $('#pf-theme'), + reset: $('#pf-reset'), cancel: $('#pf-cancel'), + }; + let prefsSnapshot = null; + + function syncPrefsInputs() { + PF.gutter.checked = prefs.gutter; + PF.wrap.checked = prefs.wrap; + PF.colmark.checked = prefs.colmark; + PF.col.value = prefs.col; + PF.hl.checked = prefs.hl; + PF.font.value = prefs.font; + PF.size.value = prefs.size; + PF.theme.value = prefs.theme; + } + + function readPrefsInputs() { + prefs.gutter = PF.gutter.checked; + prefs.wrap = PF.wrap.checked; + prefs.colmark = PF.colmark.checked; + prefs.col = Math.min(320, Math.max(20, parseInt(PF.col.value, 10) || 80)); + prefs.hl = PF.hl.checked; + prefs.font = FONT_STACKS[PF.font.value] ? PF.font.value : 'mono'; + prefs.size = Math.min(32, Math.max(10, parseInt(PF.size.value, 10) || 14)); + prefs.theme = THEMES.includes(PF.theme.value) ? PF.theme.value : 'claro'; + } + + function openPrefs() { + prefsSnapshot = { ...prefs }; + syncPrefsInputs(); + PF.overlay.classList.remove('hidden'); + PF.gutter.focus(); + } + function closePrefs() { + PF.overlay.classList.add('hidden'); + prefsSnapshot = null; + } + + PF.form.addEventListener('input', () => { readPrefsInputs(); applyPrefs(); }); + PF.form.addEventListener('submit', (e) => { + e.preventDefault(); + persistPrefs(); + closePrefs(); + toast('Preferências aplicadas.'); + }); + PF.cancel.addEventListener('click', () => { + if (prefsSnapshot) prefs = prefsSnapshot; + applyPrefs(); + closePrefs(); + }); + PF.reset.addEventListener('click', () => { + prefs = { ...PREFS_DEFAULT }; + syncPrefsInputs(); + applyPrefs(); + }); + + /* ------------------------------------------------------------ editor */ + + function markDirty() { + if (!state.dirty) { + state.dirty = true; + updateStatus(); + } + } + + E.editor.addEventListener('input', () => { + state.content = E.editor.value; + markDirty(); + updateStatus(); + scheduleRender(); + }); + ['keyup', 'mouseup', 'select', 'click'].forEach((ev) => + E.editor.addEventListener(ev, updateStatus)); + E.noteTitle.addEventListener('input', () => { markDirty(); updateStatus(); }); + E.noteCategory.addEventListener('input', markDirty); + E.formatSelect.addEventListener('change', () => { + markDirty(); updateToolbar(); updateStatus(); scheduleRender(); refreshPreviewIfOpen(); + }); + + // Tab insere 4 espaços em vez de sair do textarea. + E.editor.addEventListener('keydown', (e) => { + if (e.key === 'Tab') { + e.preventDefault(); + insertText(' '); + } + }); + + function insertText(text) { + const ta = E.editor; + ta.focus(); + if (!document.execCommand || !document.execCommand('insertText', false, text)) { + // fallback sem integração com undo nativo + const s = ta.selectionStart, en = ta.selectionEnd; + ta.setRangeText(text, s, en, 'end'); + } + state.content = ta.value; + markDirty(); + updateStatus(); + } + + function selectedRange() { + const ta = E.editor; + return { s: ta.selectionStart, e: ta.selectionEnd, sel: ta.value.slice(ta.selectionStart, ta.selectionEnd) }; + } + + // Substitui a seleção (ou `range` explícito, se dado) por `text`. + function replaceSelection(text, selStart, selEnd, range) { + const ta = E.editor; + ta.focus(); + if (range) ta.setSelectionRange(range.s, range.e); + const { s, e } = selectedRange(); + if (document.execCommand) { + document.execCommand('insertText', false, text); + } else { + ta.setRangeText(text, s, e, 'preserve'); + } + if (selStart !== undefined) ta.setSelectionRange(selStart, selEnd); + state.content = ta.value; + markDirty(); + updateStatus(); + } + + function wrapSel(before, after, placeholder) { + const { s, e, sel } = selectedRange(); + const text = sel || placeholder || 'texto'; + replaceSelection(before + text + after, s + before.length, s + before.length + text.length); + } + + function expandToLines() { + const ta = E.editor; + const v = ta.value; + const s = v.lastIndexOf('\n', Math.max(ta.selectionStart - 1, 0)) + 1; + let e = v.indexOf('\n', ta.selectionEnd); + if (e === -1) e = v.length; + return { s, e, block: v.slice(s, e) }; + } + + function eachLine(prefixFn) { + const { s, e, block } = expandToLines(); + const lines = block.split('\n'); + const out = lines.map(prefixFn).join('\n'); + replaceSelection(out, s, s + out.length, { s, e }); + } + + function applyFmt(kind) { + const fmt = currentFormat(); + if (fmt === 'txt') { toast('Em texto puro não há formatação — mude o formato para Markdown ou HTML.'); return; } + const md = fmt === 'md'; + switch (kind) { + case 'bold': md ? wrapSel('**', '**', 'negrito') : wrapSel('', '', 'negrito'); break; + case 'italic': md ? wrapSel('*', '*', 'itálico') : wrapSel('', '', 'itálico'); break; + case 'strike': md ? wrapSel('~~', '~~', 'tachado') : wrapSel('', '', 'tachado'); break; + case 'code': md ? wrapSel('`', '`', 'código') : wrapSel('', '', 'código'); break; + case 'block': + md ? wrapSel('\n```\n', '\n```\n', 'código em bloco') + : wrapSel('\n
', '
\n', 'código em bloco'); + break; + case 'link': { + const { sel } = selectedRange(); + const url = window.prompt('Endereço do link (URL):', 'https://'); + if (!url) return; + md ? wrapSel('[', `](${url})`, sel || 'link') + : replaceSelection(`${sel || 'link'}`); + break; + } + case 'quote': + md ? eachLine((l) => togglePrefix(l, '> ')) + : wrapSel('
', '
', 'citação'); + break; + case 'h1': md ? eachLine((l) => toggleHeading(l, '# ')) : eachLine((l) => wrapLine(l, '

', '

')); break; + case 'h2': md ? eachLine((l) => toggleHeading(l, '## ')) : eachLine((l) => wrapLine(l, '

', '

')); break; + case 'list-ul': + md ? eachLine((l) => togglePrefix(l, '- ')) + : (() => { const { sel } = selectedRange(); const items = (sel || 'item').split('\n'); + replaceSelection(''); })(); + break; + case 'list-ol': + md ? eachLine((l, i) => `${i + 1}. ${l.replace(/^(\d+\.|- )\s*/, '')}`) + : (() => { const { sel } = selectedRange(); const items = (sel || 'item').split('\n'); + replaceSelection('
    \n' + items.map((i) => '
  1. ' + i + '
  2. ').join('\n') + '\n
'); })(); + break; + } + } + + function togglePrefix(line, prefix) { + return line.startsWith(prefix) ? line.slice(prefix.length) : prefix + line; + } + function toggleHeading(line, prefix) { + if (line.startsWith(prefix)) return line.slice(prefix.length); + return prefix + line.replace(/^#{1,6}\s*/, ''); + } + function wrapLine(line, before, after) { + const trimmed = line.trim(); + if (trimmed.startsWith(before) && trimmed.endsWith(after)) { + return line.replace(before, '').replace(new RegExp(after + '$'), ''); + } + return before + (trimmed || 'título') + after; + } + + document.querySelectorAll('#fmt-tools .fmt').forEach((btn) => { + btn.addEventListener('click', () => applyFmt(btn.dataset.fmt)); + }); + + function updateToolbar() { + E.fmtTools.classList.toggle('disabled', currentFormat() === 'txt'); + } + + /* ------------------------------------------------------------ modos */ + + function setMode(mode) { + state.mode = mode; + E.modeToggle.querySelectorAll('.seg').forEach((b) => + b.classList.toggle('active', b.dataset.mode === mode)); + const editing = mode === 'edit'; + E.editorWrap.classList.toggle('hidden', !editing); + if (editing) { + E.preview.classList.add('hidden'); + E.previewFrame.classList.add('hidden'); + E.editor.focus(); + updateMarker(); + return; + } + E.colMarker.classList.add('hidden'); + refreshPreviewIfOpen(); + } + + function refreshPreviewIfOpen() { + if (state.mode !== 'preview') return; + const fmt = currentFormat(); + E.editorWrap.classList.add('hidden'); + if (fmt === 'html') { + E.preview.classList.add('hidden'); + E.previewFrame.classList.remove('hidden'); + E.previewFrame.srcdoc = E.editor.value; + return; + } + E.previewFrame.classList.add('hidden'); + E.preview.classList.remove('hidden'); + if (fmt === 'txt') { + E.preview.classList.remove('md'); + E.preview.textContent = E.editor.value; + return; + } + E.preview.classList.add('md'); + E.preview.textContent = '…renderizando'; + api('POST', '/api/render', { content: E.editor.value, format: 'md' }) + .then((r) => { if (state.mode === 'preview') E.preview.innerHTML = r.html; }) + .catch((err) => { E.preview.textContent = 'erro ao renderizar: ' + err.message; }); + } + + E.modeToggle.querySelectorAll('.seg').forEach((b) => + b.addEventListener('click', () => setMode(b.dataset.mode))); + + /* ------------------------------------------------------------- versões */ + + function toggleVersionsPanel(open) { + const show = open ?? E.versionsPanel.classList.contains('hidden'); + E.versionsPanel.classList.toggle('hidden', !show); + if (show) loadRevisions().catch((err) => toast('Histórico: ' + err.message)); + } + + let revReq = 0; + async function loadRevisions() { + const req = ++revReq; + if (!state.note) { + E.revisionsList.textContent = ''; + E.versionDetail.classList.add('hidden'); + return; + } + const revs = await api('GET', '/api/notes/' + state.note.id + '/revisions'); + if (req !== revReq) return; // resposta obsoleta: uma carga mais nova assumiu + E.revisionsList.textContent = ''; + for (const r of revs) { + const li = document.createElement('li'); + li.dataset.hash = r.hash; + li.className = state.selectedRev === r.hash ? 'active' : ''; + const top = document.createElement('div'); + top.className = 'rev-top'; + const author = document.createElement('span'); + author.textContent = r.author; + const hash = document.createElement('span'); + hash.className = 'rev-hash'; + hash.textContent = r.hash.slice(0, 8); + top.appendChild(author); + top.appendChild(hash); + const msg = document.createElement('div'); + msg.className = 'rev-msg'; + msg.textContent = r.message; + const when = document.createElement('div'); + when.className = 'rev-when'; + when.textContent = r.when.slice(0, 16).replace('T', ' ') + ' UTC · ' + (r.bytes) + ' B'; + li.appendChild(top); li.appendChild(msg); li.appendChild(when); + li.addEventListener('click', () => showRevisionDiff(r)); + E.revisionsList.appendChild(li); + } + } + + async function showRevisionDiff(rev) { + state.selectedRev = rev.hash; + E.revisionsList.querySelectorAll('li').forEach((li) => + li.classList.toggle('active', li.dataset.hash === rev.hash)); + try { + const dv = await api('GET', `/api/notes/${state.note.id}/revisions/${rev.hash}/diff`); + E.versionDetail.classList.remove('hidden'); + E.versionInfo.textContent = + `${dv.from} → ${dv.to} · ${dv.author} · +${dv.added}/-${dv.removed}`; + renderDiff(dv.diff || '(sem alterações)'); + E.btnRestore.disabled = state.note && rev.hash === state.note.hash; + } catch (err) { + toast('Diff: ' + err.message); + } + } + + function renderDiff(diffText) { + E.versionDiff.textContent = ''; + for (const line of diffText.split('\n')) { + const span = document.createElement('span'); + if (line.startsWith('@@')) span.className = 'd-hunk'; + else if (line.startsWith('---') || line.startsWith('+++')) span.className = 'd-head'; + else if (line.startsWith('+')) span.className = 'd-add'; + else if (line.startsWith('-')) span.className = 'd-del'; + span.textContent = line + '\n'; + E.versionDiff.appendChild(span); + } + } + + E.btnRestore.addEventListener('click', async () => { + if (!state.note || !state.selectedRev) return; + const ok = await modal({ + title: 'Restaurar versão?', + bodyHTML: `O conteúdo da nota será substituído pelo da revisão ${esc(state.selectedRev.slice(0, 8))}. O estado atual continuará no histórico.`, + withCancel: true, okLabel: 'Restaurar', + }); + if (!ok) return; + try { + const data = await api('POST', + `/api/notes/${state.note.id}/revisions/${state.selectedRev}/restore`, + { author: E.authorInput.value.trim() }); + state.note = data; + state.content = data.content; + state.dirty = false; + fillEditor(); + await Promise.all([loadNotes(), loadRevisions(), loadAggregates()]); + toast('Revisão restaurada com sucesso.'); + } catch (err) { + toast('Restauração: ' + err.message); + } + }); + + E.btnCloseVersions.addEventListener('click', () => toggleVersionsPanel(false)); + + /* ------------------------------------------------------------ menus */ + + document.querySelectorAll('.menu .menu-btn').forEach((btn) => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const menu = btn.parentElement; + const wasOpen = menu.classList.contains('open'); + document.querySelectorAll('.menu.open').forEach((m) => m.classList.remove('open')); + if (!wasOpen) menu.classList.add('open'); + }); + }); + document.addEventListener('click', () => + document.querySelectorAll('.menu.open').forEach((m) => m.classList.remove('open'))); + + const ACTIONS = { + 'new-note': () => newDraft(), + 'save': () => save(), + 'export': exportNote, + 'delete-note': deleteNote, + 'undo': () => { E.editor.focus(); document.execCommand('undo'); state.content = E.editor.value; markDirty(); updateStatus(); }, + 'redo': () => { E.editor.focus(); document.execCommand('redo'); state.content = E.editor.value; markDirty(); updateStatus(); }, + 'select-all': () => { E.editor.focus(); E.editor.setSelectionRange(0, E.editor.value.length); }, + 'toggle-sidebar': () => document.body.classList.toggle('sidebar-off'), + 'toggle-gutter': () => { prefs.gutter = !prefs.gutter; persistPrefs(); applyPrefs(); }, + 'toggle-wrap': () => { prefs.wrap = !prefs.wrap; persistPrefs(); applyPrefs(); }, + 'prefs': openPrefs, + 'toggle-edit': () => setMode('edit'), + 'toggle-preview': () => setMode('preview'), + 'toggle-versions': () => toggleVersionsPanel(), + 'about': () => modal({ + title: 'Sobre o Notas', + bodyHTML: + `

Editor de notas minimalista inspirado no Mousepad (XFCE).

+

Backend em Go, armazenamento em disco com revisões endereçadas por hash SHA-256 (estilo Git): cada salvamento de conteúdo gera um snapshot, com autor, data e diff.

+

Atalhos: Ctrl+S salvar · Ctrl+N nova · Ctrl+E editar · Ctrl+R preview · Ctrl+H histórico · F9 barra lateral · Ctrl+B/I/K formatação.

`, + }), + 'logout': async () => { + await fetch('/api/logout', { method: 'POST' }).catch(() => {}); + location.reload(); + }, + }; + document.querySelectorAll('.menu-dropdown [data-action]').forEach((li) => + li.addEventListener('click', () => { + document.querySelectorAll('.menu.open').forEach((m) => m.classList.remove('open')); + ACTIONS[li.dataset.action](); + })); + + E.btnNew.addEventListener('click', () => newDraft()); + E.btnSave.addEventListener('click', () => save()); + E.btnExport.addEventListener('click', exportNote); + E.btnVersions.addEventListener('click', () => toggleVersionsPanel()); + E.btnSidebar.addEventListener('click', ACTIONS['toggle-sidebar']); + + /* ------------------------------------------------------------ teclado */ + + document.addEventListener('keydown', (e) => { + const mod = e.ctrlKey || e.metaKey; + if (e.key === 'F9') { e.preventDefault(); document.body.classList.toggle('sidebar-off'); return; } + if (!mod) return; + switch (e.key.toLowerCase()) { + case 's': e.preventDefault(); save(); break; + case 'n': e.preventDefault(); newDraft(); break; + case 'e': e.preventDefault(); setMode('edit'); break; + case 'r': e.preventDefault(); setMode('preview'); break; + case 'h': e.preventDefault(); toggleVersionsPanel(); break; + case ',': e.preventDefault(); openPrefs(); break; + case 'b': if (e.target === E.editor) { e.preventDefault(); applyFmt('bold'); } break; + case 'i': if (e.target === E.editor) { e.preventDefault(); applyFmt('italic'); } break; + case 'k': if (e.target === E.editor) { e.preventDefault(); applyFmt('link'); } break; + } + }); + + window.addEventListener('beforeunload', (e) => { + if (state.dirty) { e.preventDefault(); e.returnValue = ''; } + }); + + /* ------------------------------------------------------------ status */ + + function updateStatus() { + const ta = E.editor; + const upto = ta.value.slice(0, ta.selectionStart); + const lines = upto.split('\n'); + E.stPos.textContent = `Ln ${lines.length}, Col ${lines[lines.length - 1].length + 1}`; + + const text = ta.value; + const words = (text.match(/\S+/g) || []).length; + E.stStats.textContent = `${text.length} caracteres · ${words} palavras · ${text.split('\n').length} linhas`; + + const fmt = currentFormat(); + E.stFormat.textContent = EXT[fmt]; + if (state.note) { + E.stFile.textContent = (E.noteTitle.value.trim() || state.note.title || 'Sem título') + EXT[fmt]; + } else { + E.stFile.textContent = E.noteTitle.value.trim() ? E.noteTitle.value.trim() + EXT[fmt] + ' (nova)' : 'rascunho sem salvar'; + } + + if (state.dirty) { + E.stSaved.textContent = '● não salvo'; + E.stSaved.className = 'st-item dirty'; + } else { + E.stSaved.textContent = state.lastSaved ? 'salvo ' + relTime(state.lastSaved) : '—'; + E.stSaved.className = 'st-item ok'; + } + } + + /* ------------------------------------------------------------ filtros */ + + let searchTimer = 0; + E.searchInput.addEventListener('input', () => { + clearTimeout(searchTimer); + searchTimer = setTimeout(() => { + state.filter.q = E.searchInput.value.trim(); + loadNotes().catch(() => {}); + }, 250); + }); + E.categorySelect.addEventListener('change', () => { + state.filter.category = E.categorySelect.value; + loadNotes().catch(() => {}); + }); + + /* ------------------------------------------------------------ autor */ + + E.authorInput.value = localStorage.getItem('notas_author') || ''; + E.authorInput.addEventListener('change', () => + localStorage.setItem('notas_author', E.authorInput.value.trim())); + + /* ------------------------------------------------------------ boot */ + + async function boot() { + applyPrefs(); + await Promise.all([loadNotes(), loadAggregates()]); + if (state.notes.length) await openNote(state.notes[0].id); + else await newDraft(true); + updateStatus(); + } + + window.addEventListener('unhandledrejection', (e) => { + if (e.reason instanceof ApiError && e.reason.status === 401) return; // login já exibido + console.error(e.reason); + if (e.reason instanceof ApiError) toast('Erro: ' + e.reason.message); + }); + + (async () => { + try { + await api('GET', '/api/me'); + await boot(); + } catch (err) { + if (err instanceof ApiError && err.status === 401) showLogin(); + else toast('Erro ao iniciar: ' + err.message); + } + })(); +})();