feat(files): gerenciador de arquivos/anexos com árvore de pastas, upload, preview e picker

- store/files.go: pastas (parent, anti-ciclo), FileMeta (nota opcional = avulso),
  blobs com extensão neutralizada p/ tipos perigosos, sanitização de nome
- api/files.go: upload multipart (64MiB), tree, static com ETag/Range + CSP
  sandbox + attachment p/ html/svg/js (stored-XSS mitigado), move/rename/delete,
  CRUD de pastas
- web: overlay Ctrl+J / 📎 (modo seletor insere sintaxe md/html/txt e associa),
  filtros Todos/Anexos/Avulsos/Nota atual, busca, lightbox img/video/audio/pdf/texto,
  colar-e-arrastar no editor e no gerenciador, ações por card (preview/associar/
  mover/renomear/excluir), mobile-first (ações sempre visíveis)
- fix: api() já serializa o body — chamadas do gerenciador passavam JSON pré-string
  (dupla codificação); auth por cookie (sem Bearer manual no fetch)
- sw v10; testes store+api (traversal, ciclo, XSS headers, range); README
This commit is contained in:
lgcosta committed 2026-11-05 10:32:40 -03:00
1 parent ea6b3b915d
commit 119e6cefe9
10 files changed
+1605 -4

No files matched your search

+119
View File
@@ -1800,3 +1800,122 @@ html[data-theme="carbon-paper"] #notes-list li.active,
html[data-theme="carbon-paper"] #revisions-list li.active { background: #c2ccd5; }
html[data-theme="carbon-paper"] #notes-list li:hover,
html[data-theme="carbon-paper"] #revisions-list li:hover { background: #e6e5dd; }
/* ------------------------------------------------- gerenciador de arquivos */
.files-overlay {
position: fixed;
inset: 4vh 3vw;
z-index: 95;
display: flex;
flex-direction: column;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 16px;
box-shadow: 0 24px 80px rgba(0, 0, 0, .35);
overflow: hidden;
}
.files-head {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
padding: 10px 14px;
border-bottom: 1px solid var(--border-soft);
background: var(--panel-2);
}
.files-title { font-weight: 700; font-size: 15px; margin-right: 6px; }
.files-filters { display: flex; gap: 5px; flex-wrap: wrap; }
#files-search {
flex: 1 1 120px;
max-width: 220px;
font: inherit;
font-size: 13px;
padding: 5px 9px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel);
color: var(--fg);
}
.files-body { display: flex; flex: 1 1 auto; min-height: 0; }
.files-tree {
width: 264px;
flex: none;
overflow-y: auto;
border-right: 1px solid var(--border-soft);
padding: 8px 6px;
background: var(--panel-2);
}
.ftree-acts { display: none; gap: 2px; }
.ftree-row:hover .ftree-acts, .ftree-row.active .ftree-acts { display: inline-flex; }
.ftree-act {
border: 0;
background: none;
cursor: pointer;
font-size: 11px;
color: var(--muted);
padding: 1px 4px;
border-radius: 4px;
}
.ftree-act:hover { background: var(--accent); color: #fff; }
.files-list {
flex: 1 1 auto;
min-width: 0;
overflow-y: auto;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.files-empty { padding: 24px 12px; text-align: center; }
.fitem {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border: 1px solid var(--border-soft);
border-radius: 12px;
background: var(--panel);
box-shadow: 0 1px 3px rgba(0, 0, 0, .06);
cursor: pointer;
transition: transform .1s ease, box-shadow .12s ease, border-color .12s ease;
}
.fitem:hover { transform: translateY(-1px); border-color: var(--accent); box-shadow: 0 4px 14px -4px rgba(0, 0, 0, .2); }
.fitem:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.fitem-icon { font-size: 22px; flex: none; }
.fitem-main { flex: 1 1 auto; min-width: 0; }
.fitem-name { font-weight: 600; font-size: 13.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fitem-meta { font-size: 11.5px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fitem-acts { display: flex; gap: 3px; flex: none; opacity: 0; transition: opacity .12s ease; }
.fitem:hover .fitem-acts, .fitem:focus-within .fitem-acts { opacity: 1; }
.dropzone.drop-hl, #editor-host.drop-hl {
outline: 2px dashed var(--accent);
outline-offset: -4px;
background: color-mix(in srgb, var(--accent) 7%, transparent);
}
#editor-host { position: relative; }
/* lightbox */
.lightbox-box {
width: min(940px, 94vw);
max-height: 92vh;
display: flex;
flex-direction: column;
gap: 10px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
padding: 14px;
}
.lightbox-body { flex: 1 1 auto; min-height: 0; overflow: auto; display: flex; justify-content: center; }
.lightbox-body img { max-width: 100%; max-height: 74vh; border-radius: 8px; }
.lightbox-body video { width: 100%; max-height: 74vh; }
.lightbox-body audio { width: 100%; }
.lightbox-body iframe { width: 100%; height: 70vh; border: 1px solid var(--border-soft); border-radius: 8px; }
.lightbox-text { width: 100%; max-height: 66vh; overflow: auto; background: var(--panel-2); border-radius: 8px; padding: 12px; font-size: 12.5px; white-space: pre-wrap; word-break: break-word; }
@media (max-width: 760px) {
.files-overlay { inset: 0; border-radius: 0; }
.files-tree { width: 172px; }
.fitem-acts { opacity: 1; flex-wrap: wrap; justify-content: flex-end; max-width: 40%; }
.ftree-acts { display: inline-flex; }
}
+32
View File
@@ -113,6 +113,36 @@
</form>
</div>
<!-- Gerenciador de arquivos / anexos -->
<div id="files-overlay" class="files-overlay hidden">
<div class="files-head">
<span class="files-title">Arquivos</span>
<div id="files-filters" class="files-filters"></div>
<input id="files-search" type="search" placeholder="Buscar…" autocomplete="off">
<button type="button" class="btn btn-primary btn-small" id="files-upload-btn">⬆ Enviar</button>
<button type="button" class="btn btn-small" id="files-newfolder">+ Pasta</button>
<button type="button" class="btn btn-icon btn-small" id="files-close" title="Fechar">✕</button>
</div>
<div class="files-body">
<aside id="files-tree" class="files-tree"></aside>
<section id="files-list" class="files-list dropzone">
<div id="files-empty" class="files-empty muted">Solte arquivos aqui ou use “⬆ Enviar”.</div>
</section>
</div>
<input type="file" id="files-input" multiple class="hidden">
</div>
<!-- Lightbox de preview -->
<div id="lightbox" class="overlay hidden">
<div class="lightbox-box">
<div id="lightbox-body" class="lightbox-body"></div>
<div class="dialog-actions">
<a id="lightbox-dl" class="btn btn-primary" download>Baixar</a>
<button type="button" id="lightbox-close" class="btn">Fechar</button>
</div>
</div>
</div>
<!-- Modal genérico (Sobre / confirmar) -->
<div id="modal-overlay" class="overlay hidden">
<div class="dialog">
@@ -146,6 +176,7 @@
<li data-action="new-note">Nova nota<span class="accel">Ctrl+N</span></li>
<li data-action="save">Salvar<span class="accel">Ctrl+S</span></li>
<li data-action="export">Baixar arquivo (.txt/.md/.html)…</li>
<li data-action="files">Gerenciador de arquivos…<span class="accel">Ctrl+J</span></li>
<li class="menu-sep"></li>
<li data-action="close-tab">Fechar aba<span class="accel">Alt+W</span></li>
<li data-action="delete-note">Excluir esta nota…</li>
@@ -192,6 +223,7 @@
<button type="button" class="btn btn-icon" id="btn-new" title="Nova nota (Ctrl+N)">✚<span class="tb-label">Nova</span></button>
<button type="button" class="btn btn-icon" id="btn-save" title="Salvar (Ctrl+S)">💾<span class="tb-label">Salvar</span></button>
<button type="button" class="btn btn-icon" id="btn-export" title="Baixar arquivo">⬇<span class="tb-label">Baixar</span></button>
<button type="button" class="btn btn-icon" id="btn-attach" title="Inserir anexo (📎)">📎<span class="tb-label">Anexo</span></button>
</div>
<span class="tb-sep"></span>
<div class="tb-group" id="fmt-tools">
+449
View File
@@ -1439,6 +1439,8 @@
persistTabs();
});
E.wysiwyg.addEventListener('paste', (e) => {
const files = e.clipboardData && e.clipboardData.files;
if (files && files.length) { e.preventDefault(); handleFiles(files, 'editor'); return; }
e.preventDefault(); // sempre texto puro: nada de HTML grudento de fora
const text = (e.clipboardData || window.clipboardData).getData('text/plain');
E.wysiwyg.focus();
@@ -1785,6 +1787,7 @@
'new-note': () => newDraftTab(),
'save': () => save(),
'export': exportNote,
'files': () => openFiles(false),
'close-tab': () => closeTab(state.activeKey),
'delete-note': deleteNote,
'undo': () => { E.editor.focus(); document.execCommand('undo'); const t = activeTab(); if (t) t.content = E.editor.value; markDirty(); scheduleRender(); updateStatus(); },
@@ -1835,6 +1838,7 @@
E.btnTabNew.addEventListener('click', () => newDraftTab());
E.btnSave.addEventListener('click', () => save());
E.btnExport.addEventListener('click', exportNote);
$('#btn-attach').addEventListener('click', () => openFiles(true));
E.btnVersions.addEventListener('click', () => toggleVersionsPanel());
E.btnSidebar.addEventListener('click', toggleSidebar);
@@ -1858,6 +1862,7 @@
case 'r': e.preventDefault(); setMode('preview'); break;
case 'h': e.preventDefault(); toggleVersionsPanel(); break;
case 'm': e.preventDefault(); cycleMode(); break;
case 'j': e.preventDefault(); openFiles(false); 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;
@@ -1953,6 +1958,450 @@
E.authorInput.addEventListener('change', () =>
localStorage.setItem('notas_author', E.authorInput.value.trim()));
/* ------------------------------------------------ gerenciador de arquivos */
const F = {
overlay: $('#files-overlay'), tree: $('#files-tree'), list: $('#files-list'),
empty: $('#files-empty'), filters: $('#files-filters'), search: $('#files-search'),
input: $('#files-input'), uploadBtn: $('#files-upload-btn'), newFolderBtn: $('#files-newfolder'),
closeBtn: $('#files-close'), lightbox: $('#lightbox'), lbBody: $('#lightbox-body'),
lbDl: $('#lightbox-dl'), lbClose: $('#lightbox-close'),
};
const filesState = {
tree: [], rootFiles: [], folders: new Map(), files: new Map(),
folder: '', // '' = raiz | '__all__' = todos | id
filter: 'all', // all | attach | loose | note
search: '',
picker: false,
expanded: new Set(JSON.parse(localStorage.getItem('notas_files_expanded') || '[]')),
};
const iconFor = (mime) => mime == null ? '📄'
: mime.startsWith('image/') ? '🖼️'
: mime.startsWith('video/') ? '🎬'
: mime.startsWith('audio/') ? '🎵'
: mime.includes('pdf') ? '📕'
: mime.includes('zip') || mime.includes('compressed') ? '🗜️'
: mime.startsWith('text/') ? '📝' : '📄';
const fmtBytes = (n) => n < 1024 ? n + ' B'
: n < 1048576 ? (n / 1024).toFixed(1) + ' KiB' : (n / 1048576).toFixed(1) + ' MiB';
async function loadFiles() {
const d = await api('GET', '/api/files/tree');
filesState.tree = d.tree || [];
filesState.rootFiles = d.rootFiles || [];
filesState.folders = new Map();
filesState.files = new Map();
(function walk(nodes) {
for (const n of nodes) {
filesState.folders.set(n.id, n);
(n.files || []).forEach((f) => filesState.files.set(f.id, f));
walk(n.children || []);
}
})(filesState.tree);
filesState.rootFiles.forEach((f) => filesState.files.set(f.id, f));
renderFilesUI();
}
function filesInScope() {
let arr;
if (filesState.folder === '__all__') arr = [...filesState.files.values()];
else if (filesState.folder === '') arr = filesState.rootFiles.slice();
else {
const n = filesState.folders.get(filesState.folder);
arr = n ? (n.files || []).slice() : [];
}
const t = activeTab();
if (filesState.filter === 'attach') arr = arr.filter((f) => f.note);
else if (filesState.filter === 'loose') arr = arr.filter((f) => !f.note);
else if (filesState.filter === 'note') arr = [...filesState.files.values()].filter((f) => t && f.note === t.id);
if (filesState.search) {
const q = filesState.search.toLowerCase();
arr = arr.filter((f) => f.name.toLowerCase().includes(q));
}
return arr.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
}
function renderFilesUI() {
renderFilesFilters();
renderFilesTree();
renderFilesList();
}
function renderFilesFilters() {
F.filters.textContent = '';
const mk = (value, label) => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'tag-chip' + (filesState.filter === value ? ' active' : '');
b.textContent = label;
b.addEventListener('click', () => { filesState.filter = value; renderFilesUI(); });
F.filters.appendChild(b);
};
mk('all', 'Todos');
mk('attach', 'Anexos');
mk('loose', 'Avulsos');
mk('note', '📌 Nota atual');
}
function folderRow(node, depth) {
const row = document.createElement('div');
row.className = 'tree-row ftree-row' + (filesState.folder === node.id ? ' active' : '');
row.style.paddingLeft = (8 + depth * 14) + 'px';
const caret = document.createElement('button');
caret.type = 'button';
caret.className = 'tree-caret';
const hasKids = (node.children || []).length > 0;
caret.textContent = hasKids ? (filesState.expanded.has(node.id) ? '▾' : '▸') : '·';
caret.addEventListener('click', (e) => {
e.stopPropagation();
if (!hasKids) return;
if (filesState.expanded.has(node.id)) filesState.expanded.delete(node.id);
else filesState.expanded.add(node.id);
localStorage.setItem('notas_files_expanded', JSON.stringify([...filesState.expanded]));
renderFilesTree();
});
row.appendChild(caret);
const nm = document.createElement('span');
nm.className = 'tree-name';
nm.textContent = '📁 ' + node.name;
row.appendChild(nm);
const count = document.createElement('span');
count.className = 'tree-count';
count.textContent = String((node.files || []).length);
row.appendChild(count);
const acts = document.createElement('span');
acts.className = 'ftree-acts';
const mkAct = (label, title, fn) => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'ftree-act';
b.textContent = label;
b.title = title;
b.addEventListener('click', (e) => { e.stopPropagation(); fn(); });
acts.appendChild(b);
};
mkAct('+', 'Nova subpasta', () => createFolder(node.id));
mkAct('✎', 'Renomear', async () => {
const name = window.prompt('Novo nome da pasta:', node.name);
if (!name || !name.trim() || name.trim() === node.name) return;
try { await api('PUT', '/api/folders/' + node.id, { name: name.trim() }); await loadFiles(); }
catch (err) { toast('Renomear pasta: ' + err.message); }
});
mkAct('✕', 'Excluir pasta', async () => {
const ok = await confirmModal('Excluir pasta?',
`<b>${esc(node.name)}</b>, suas subpastas e TODOS os arquivos dentro serão apagados (inclusive anexos usados em notas).`, 'Excluir');
if (!ok) return;
try { await api('DELETE', '/api/folders/' + node.id); await loadFiles(); toast('Pasta excluída.'); }
catch (err) { toast('Excluir pasta: ' + err.message); }
});
row.appendChild(acts);
row.addEventListener('click', () => { filesState.folder = node.id; renderFilesUI(); });
return row;
}
function renderFilesTree() {
F.tree.textContent = '';
const special = (value, label) => {
const row = document.createElement('div');
row.className = 'tree-row' + (filesState.folder === value ? ' active' : '');
row.style.paddingLeft = '8px';
const nm = document.createElement('span');
nm.className = 'tree-name';
nm.textContent = label;
row.appendChild(nm);
row.addEventListener('click', () => { filesState.folder = value; renderFilesUI(); });
F.tree.appendChild(row);
};
special('__all__', '🗂 Todos os arquivos');
special('', '🏠 Raiz');
(function walk(nodes, depth) {
for (const n of nodes) {
F.tree.appendChild(folderRow(n, depth));
if (filesState.expanded.has(n.id)) walk(n.children || [], depth + 1);
}
})(filesState.tree, 0);
}
function fileRow(f) {
const row = document.createElement('div');
row.className = 'fitem' + (filesState.picker ? ' pickable' : '');
row.tabIndex = 0;
const ic = document.createElement('span');
ic.className = 'fitem-icon';
ic.textContent = iconFor(f.mime);
row.appendChild(ic);
const main = document.createElement('div');
main.className = 'fitem-main';
const nm = document.createElement('div');
nm.className = 'fitem-name';
nm.textContent = f.name;
main.appendChild(nm);
const meta = document.createElement('div');
meta.className = 'fitem-meta';
const bits = [fmtBytes(f.size), f.mime, relTime(f.created_at)];
if (f.note) {
const tn = state.notes.find((n) => n.id === f.note);
bits.push('📌 ' + (tn ? tn.title || 'Sem título' : 'nota?'));
}
meta.textContent = bits.join(' · ');
main.appendChild(meta);
row.appendChild(main);
const acts = document.createElement('div');
acts.className = 'fitem-acts';
const mkAct = (label, title, fn) => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'btn btn-small';
b.textContent = label;
b.title = title;
b.addEventListener('click', (e) => { e.stopPropagation(); fn(); });
acts.appendChild(b);
};
mkAct('👁', 'Visualizar', () => previewFile(f));
if (!filesState.picker) {
mkAct('📌', 'Associar à nota atual', async () => {
const t = activeTab();
if (!t || !t.id) { toast('Salve a nota primeiro para associar anexos.'); return; }
try { await api('PUT', '/api/files/' + f.id + '/move', { note: t.id }); await loadFiles(); }
catch (err) { toast('Associar: ' + err.message); }
});
if (f.note) {
mkAct('📎-', 'Desvincular da nota', async () => {
try { await api('PUT', '/api/files/' + f.id + '/move', { note: '' }); await loadFiles(); }
catch (err) { toast('Desvincular: ' + err.message); }
});
}
mkAct('📂', 'Mover de pasta', async () => {
const opts = ['(nenhuma mudança)'];
const lines = ['0: Raiz'];
let i = 1;
(function walk(nodes) {
for (const n of nodes) { lines.push(i + ': ' + n.name); opts.push(n.id); i++; walk(n.children || []); }
})(filesState.tree);
const ans = window.prompt('Mover para:\n' + lines.join('\n'), '0');
if (ans == null) return;
const idx = parseInt(ans, 10);
if (isNaN(idx) || idx < 0 || idx >= opts.length) return;
try { await api('PUT', '/api/files/' + f.id + '/move', { folder: opts[idx] || '' }); await loadFiles(); }
catch (err) { toast('Mover: ' + err.message); }
});
mkAct('✎', 'Renomear', async () => {
const name = window.prompt('Novo nome:', f.name);
if (!name || !name.trim() || name.trim() === f.name) return;
try { await api('PUT', '/api/files/' + f.id + '/move', { name: name.trim() }); await loadFiles(); }
catch (err) { toast('Renomear: ' + err.message); }
});
mkAct('✕', 'Excluir arquivo', async () => {
const ok = await confirmModal('Excluir arquivo?', `<b>${esc(f.name)}</b> será apagado permanentemente.`, 'Excluir');
if (!ok) return;
try { await api('DELETE', '/api/files/' + f.id); await loadFiles(); toast('Arquivo excluído.'); }
catch (err) { toast('Excluir: ' + err.message); }
});
}
row.appendChild(acts);
row.addEventListener('click', () => (filesState.picker ? pickFile(f) : previewFile(f)));
row.addEventListener('keydown', (e) => { if (e.key === 'Enter') filesState.picker ? pickFile(f) : previewFile(f); });
return row;
}
function renderFilesList() {
F.list.textContent = '';
const arr = filesInScope();
if (!arr.length) {
const d = document.createElement('div');
d.className = 'files-empty muted';
d.textContent = filesState.picker
? 'Nenhum arquivo aqui — envie um ou navegue pelas pastas.'
: 'Solte arquivos aqui ou use “⬆ Enviar”.';
F.list.appendChild(d);
return;
}
for (const f of arr) F.list.appendChild(fileRow(f));
}
async function createFolder(parent) {
const name = window.prompt(parent ? 'Nome da subpasta:' : 'Nome da nova pasta:');
if (!name || !name.trim()) return;
try {
await api('POST', '/api/folders', { name: name.trim(), parent: parent || '' });
if (parent) filesState.expanded.add(parent);
localStorage.setItem('notas_files_expanded', JSON.stringify([...filesState.expanded]));
await loadFiles();
} catch (err) { toast('Criar pasta: ' + err.message); }
}
async function uploadOne(file, folder, note) {
const fd = new FormData();
fd.append('file', file, file.name);
if (folder) fd.append('folder_id', folder);
if (note) fd.append('note_id', note);
const resp = await fetch('/api/files/upload', { method: 'POST', body: fd });
if (resp.status === 401) { showLogin(); throw new ApiError('não autenticado', 401); }
const data = await resp.json().catch(() => ({}));
if (!resp.ok) { const e = new ApiError(data.error || resp.statusText, resp.status); e.data = data; throw e; }
return data;
}
async function handleFiles(fileList, target) {
const files = [...fileList];
if (!files.length) return;
const t = activeTab();
const note = t && t.id ? t.id : '';
const folder = target === 'manager' ? (filesState.folder === '__all__' ? '' : filesState.folder) : '';
toast(`Enviando ${files.length} arquivo(s)…`);
const done = [];
for (const file of files) {
try { done.push(await uploadOne(file, folder, note)); }
catch (err) { toast(`Falha em ${file.name}: ${err.message}`); }
}
if (target === 'manager') { await loadFiles(); toast(`${done.length} arquivo(s) no gerenciador.`); return; }
for (const f of done) insertAttachment(f);
await loadFiles().catch(() => {});
if (done.length) toast(done.length === files.length
? 'Anexo(s) inserido(s) no editor' : `Inseridos ${done.length}/${files.length}`);
}
function attachmentSyntax(f, fmt) {
const isImg = f.mime.startsWith('image/') && f.mime !== 'image/svg+xml';
const isVid = f.mime.startsWith('video/');
const isAud = f.mime.startsWith('audio/');
if (fmt === 'html') {
if (isImg) return `<img src="${f.url}" alt="${esc(f.name)}">`;
if (isVid) return `<video controls src="${f.url}"></video>`;
if (isAud) return `<audio controls src="${f.url}"></audio>`;
return `<a href="${f.url}" target="_blank" rel="noopener">${esc(f.name)}</a>`;
}
if (isImg) return `![${f.name}](${f.url})`;
return `[${(isVid ? '▶ ' : isAud ? ' ' : '') + f.name}](${f.url})`;
}
function attachmentHtml(f) {
const isImg = f.mime.startsWith('image/') && f.mime !== 'image/svg+xml';
const isVid = f.mime.startsWith('video/');
const isAud = f.mime.startsWith('audio/');
if (isImg) return `<img src="${f.url}" alt="${esc(f.name)}">`;
if (isVid) return `<video controls src="${f.url}"></video>`;
if (isAud) return `<audio controls src="${f.url}"></audio>`;
return `<a href="${f.url}" target="_blank" rel="noopener">${esc(f.name)}</a>`;
}
function insertAttachment(f) {
const fmt = currentFormat();
if (modeState.mode === 'wysiwyg') {
E.wysiwyg.focus();
document.execCommand('insertHTML', false, attachmentHtml(f));
E.wysiwyg.dispatchEvent(new Event('input', { bubbles: true }));
return;
}
if (modeState.mode === 'preview') setMode('edit');
const snippet = attachmentSyntax(f, fmt);
E.editor.focus();
insertText(snippet);
markDirty();
scheduleAutosave();
}
function pickFile(f) {
insertAttachment(f);
if (f.note !== (activeTab() && activeTab().id) && activeTab() && activeTab().id) {
api('PUT', '/api/files/' + f.id + '/move', { note: activeTab().id }).catch(() => {});
}
closeFiles();
}
function openFiles(picker) {
filesState.picker = !!picker;
F.overlay.classList.remove('hidden');
F.overlay.setAttribute('aria-hidden', 'false');
if (picker) filesState.filter = 'all';
loadFiles().catch((err) => toast('Arquivos: ' + err.message));
}
function closeFiles() {
filesState.picker = false;
F.overlay.classList.add('hidden');
F.overlay.setAttribute('aria-hidden', 'true');
}
async function previewFile(f) {
F.lbDl.href = f.url;
F.lbDl.setAttribute('download', f.name);
F.lbBody.textContent = '';
const isImg = f.mime.startsWith('image/') && f.mime !== 'image/svg+xml';
const isVid = f.mime.startsWith('video/');
const isAud = f.mime.startsWith('audio/');
if (isImg || isVid || isAud || f.mime.includes('pdf')) {
let el;
if (isImg) { el = document.createElement('img'); el.src = f.url; el.alt = f.name; }
else if (isVid) { el = document.createElement('video'); el.src = f.url; el.controls = true; }
else if (isAud) { el = document.createElement('audio'); el.src = f.url; el.controls = true; }
else { el = document.createElement('iframe'); el.src = f.url; el.title = f.name; }
F.lbBody.appendChild(el);
} else {
const pre = document.createElement('pre');
pre.className = 'lightbox-text';
pre.textContent = 'carregando…';
F.lbBody.appendChild(pre);
try {
const r = await fetch(f.url);
pre.textContent = (await r.text()).slice(0, 200000);
} catch { pre.textContent = '(sem conexão para pré-visualizar)'; }
}
F.lightbox.classList.remove('hidden');
}
// wiring de eventos do gerenciador
F.closeBtn.addEventListener('click', closeFiles);
F.lbClose.addEventListener('click', () => F.lightbox.classList.add('hidden'));
F.lightbox.addEventListener('click', (e) => { if (e.target === F.lightbox) F.lightbox.classList.add('hidden'); });
F.uploadBtn.addEventListener('click', () => F.input.click());
F.input.addEventListener('change', () => { handleFiles(F.input.files, 'manager'); F.input.value = ''; });
F.newFolderBtn.addEventListener('click', () =>
createFolder(filesState.folder !== '__all__' ? filesState.folder : ''));
F.search.addEventListener('input', () => { filesState.search = F.search.value.trim(); renderFilesList(); });
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (!F.lightbox.classList.contains('hidden')) F.lightbox.classList.add('hidden');
else if (!F.overlay.classList.contains('hidden')) closeFiles();
}
});
// drag & drop no gerenciador
['dragenter', 'dragover'].forEach((ev) => F.list.addEventListener(ev, (e) => {
e.preventDefault(); F.list.classList.add('drop-hl');
}));
['dragleave', 'drop'].forEach((ev) => F.list.addEventListener(ev, (e) => {
e.preventDefault(); F.list.classList.remove('drop-hl');
if (ev === 'drop' && e.dataTransfer && e.dataTransfer.files.length) handleFiles(e.dataTransfer.files, 'manager');
}));
// drag & drop no editor (textarea + wysiwyg)
const host = $('#editor-host');
['dragenter', 'dragover'].forEach((ev) => host.addEventListener(ev, (e) => {
if (e.dataTransfer && [...e.dataTransfer.types].includes('Files')) { e.preventDefault(); host.classList.add('drop-hl'); }
}));
['dragleave', 'drop'].forEach((ev) => host.addEventListener(ev, (e) => {
host.classList.remove('drop-hl');
if (ev === 'drop' && e.dataTransfer && e.dataTransfer.files.length) {
e.preventDefault();
handleFiles(e.dataTransfer.files, 'editor');
}
}));
// paste com arquivos no textarea (o wysiwyg tem handler próprio que cai aqui)
E.editor.addEventListener('paste', (e) => {
const files = e.clipboardData && e.clipboardData.files;
if (files && files.length) { e.preventDefault(); handleFiles(files, 'editor'); }
});
/* ------------------------------------------------------------ PWA */
const isStandalone = () =>
+1 -1
View File
@@ -1,7 +1,7 @@
/* Service Worker do Notas — caches versionados.
* BUMP de CACHE em todo deploy que mude shell/assets (o activate limpa os antigos).
*/
const CACHE = 'notas-shell-v8';
const CACHE = 'notas-shell-v10';
const SHELL = [
'/',
'/css/app.css',