Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'refactor(landing): decompose landing.ts into focused modules' (#288) from refactor/landing-decompose into main

scott f0a2f835 4dc54d98

+1489 -1197
+161
src/landing-create.ts
··· 1 + /** 2 + * Document creation functions: new doc/sheet/form/slide/diagram, 3 + * template-based creation, and daily notes. 4 + * 5 + * Extracted from landing.ts for decomposition. 6 + */ 7 + 8 + import type { DocumentMeta, FolderAssignments } from './landing-types.js'; 9 + import { generateKey, exportKey } from './lib/crypto.js'; 10 + import { storeKey, pushKeysToServer } from './lib/key-sync.js'; 11 + import { formatDailyNoteName, findDailyNote, getDailyNoteTemplate } from './daily-notes.js'; 12 + import { getTemplate } from './templates.js'; 13 + import { trackRecentDoc } from './landing-utils.js'; 14 + import { moveToFolder } from './landing-utils.js'; 15 + import { showToast } from './landing-toast.js'; 16 + 17 + // --- Document type routing --- 18 + const DOC_PATH_MAP: Record<string, string> = { doc: '/docs', sheet: '/sheets', form: '/forms', slide: '/slides', diagram: '/diagrams' }; 19 + function docPathLocal(type: string): string { return DOC_PATH_MAP[type] || '/sheets'; } 20 + 21 + // ── Deps interface ─────────────────────────────────────────── 22 + 23 + export interface CreateDeps { 24 + getCurrentFolderId: () => string | null; 25 + getFolderAssignments: () => FolderAssignments; 26 + setFolderAssignments: (a: FolderAssignments) => void; 27 + getRecentIds: () => string[]; 28 + setRecentIds: (ids: string[]) => void; 29 + getAllDocs: () => DocumentMeta[]; 30 + } 31 + 32 + // ── Create document ────────────────────────────────────────── 33 + 34 + export async function createDocument(deps: CreateDeps, type: 'doc' | 'sheet' | 'form' | 'slide' | 'diagram'): Promise<void> { 35 + const key = await generateKey(); 36 + const keyStr = await exportKey(key); 37 + 38 + const nameMap = { doc: 'Untitled Document', sheet: 'Untitled Spreadsheet', form: 'Untitled Form', slide: 'Untitled Presentation', diagram: 'Untitled Diagram' }; 39 + const defaultName = nameMap[type]; 40 + const nameBytes = new TextEncoder().encode(defaultName); 41 + const { encrypt } = await import('./lib/crypto.js'); 42 + const encryptedName = await encrypt(nameBytes, key); 43 + const nameB64 = btoa(String.fromCharCode(...encryptedName)); 44 + 45 + const res = await fetch('/api/documents', { 46 + method: 'POST', 47 + headers: { 'Content-Type': 'application/json' }, 48 + body: JSON.stringify({ type, name_encrypted: nameB64 }), 49 + }); 50 + if (!res.ok) { showToast('Failed to create document', 4000, true); return; } 51 + const { id } = await res.json(); 52 + 53 + storeKey(id, keyStr); 54 + pushKeysToServer({ [id]: keyStr }); 55 + 56 + // If we're inside a folder, assign the new doc to it 57 + const currentFolderId = deps.getCurrentFolderId(); 58 + if (currentFolderId) { 59 + const updated = moveToFolder(deps.getFolderAssignments(), id, currentFolderId); 60 + deps.setFolderAssignments(updated); 61 + localStorage.setItem('tools-folder-assignments', JSON.stringify(updated)); 62 + } 63 + 64 + const updatedRecent = trackRecentDoc(deps.getRecentIds(), id); 65 + deps.setRecentIds(updatedRecent); 66 + localStorage.setItem('tools-recent', JSON.stringify(updatedRecent)); 67 + 68 + window.location.href = `${docPathLocal(type)}/${id}#${keyStr}`; 69 + } 70 + 71 + // ── Create from template ───────────────────────────────────── 72 + 73 + export async function createFromTemplate(deps: CreateDeps, templateId: string): Promise<void> { 74 + const template = getTemplate(templateId); 75 + if (!template) return; 76 + 77 + const key = await generateKey(); 78 + const keyStr = await exportKey(key); 79 + 80 + const nameBytes = new TextEncoder().encode(template.name); 81 + const { encrypt } = await import('./lib/crypto.js'); 82 + const encryptedName = await encrypt(nameBytes, key); 83 + const nameB64 = btoa(String.fromCharCode(...encryptedName)); 84 + 85 + const res = await fetch('/api/documents', { 86 + method: 'POST', 87 + headers: { 'Content-Type': 'application/json' }, 88 + body: JSON.stringify({ type: template.type, name_encrypted: nameB64 }), 89 + }); 90 + if (!res.ok) { showToast('Failed to create document', 4000, true); return; } 91 + const { id } = await res.json(); 92 + 93 + storeKey(id, keyStr); 94 + pushKeysToServer({ [id]: keyStr }); 95 + 96 + const currentFolderId = deps.getCurrentFolderId(); 97 + if (currentFolderId) { 98 + const updated = moveToFolder(deps.getFolderAssignments(), id, currentFolderId); 99 + deps.setFolderAssignments(updated); 100 + localStorage.setItem('tools-folder-assignments', JSON.stringify(updated)); 101 + } 102 + 103 + // Store template content for the editor to pick up 104 + sessionStorage.setItem(`template-content-${id}`, template.content); 105 + sessionStorage.setItem(`template-type-${id}`, template.type); 106 + 107 + const updatedRecent = trackRecentDoc(deps.getRecentIds(), id); 108 + deps.setRecentIds(updatedRecent); 109 + localStorage.setItem('tools-recent', JSON.stringify(updatedRecent)); 110 + 111 + const path = template.type === 'doc' ? '/docs' : '/sheets'; 112 + window.location.href = `${path}/${id}#${keyStr}`; 113 + } 114 + 115 + // ── Daily note ─────────────────────────────────────────────── 116 + 117 + export async function openDailyNote(deps: CreateDeps): Promise<void> { 118 + // Check if today's note already exists 119 + const existingId = findDailyNote(deps.getAllDocs()); 120 + if (existingId) { 121 + const keys = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 122 + const keyStr = keys[existingId]; 123 + if (keyStr) { 124 + const updatedRecent = trackRecentDoc(deps.getRecentIds(), existingId); 125 + deps.setRecentIds(updatedRecent); 126 + localStorage.setItem('tools-recent', JSON.stringify(updatedRecent)); 127 + window.location.href = `/docs/${existingId}#${keyStr}`; 128 + return; 129 + } 130 + } 131 + 132 + // Create a new daily note 133 + const key = await generateKey(); 134 + const keyStr = await exportKey(key); 135 + const name = formatDailyNoteName(); 136 + const nameBytes = new TextEncoder().encode(name); 137 + const { encrypt } = await import('./lib/crypto.js'); 138 + const encryptedName = await encrypt(nameBytes, key); 139 + const nameB64 = btoa(String.fromCharCode(...encryptedName)); 140 + 141 + const res = await fetch('/api/documents', { 142 + method: 'POST', 143 + headers: { 'Content-Type': 'application/json' }, 144 + body: JSON.stringify({ type: 'doc', name_encrypted: nameB64 }), 145 + }); 146 + if (!res.ok) { showToast('Failed to create document', 4000, true); return; } 147 + const { id } = await res.json(); 148 + 149 + storeKey(id, keyStr); 150 + pushKeysToServer({ [id]: keyStr }); 151 + 152 + // Store template for the editor to pick up 153 + const template = getDailyNoteTemplate(); 154 + sessionStorage.setItem('daily-note-template-' + id, template); 155 + 156 + const updatedRecent = trackRecentDoc(deps.getRecentIds(), id); 157 + deps.setRecentIds(updatedRecent); 158 + localStorage.setItem('tools-recent', JSON.stringify(updatedRecent)); 159 + 160 + window.location.href = `/docs/${id}#${keyStr}`; 161 + }
+606
src/landing-events.ts
··· 1 + /** 2 + * Event listeners for the landing page: delegated click handlers on 3 + * document list, folder list, trash, breadcrumbs, move modal, tag filter, 4 + * recent section, plus one-off listeners for sort, search, folders, 5 + * modals, username, view toggle, and trash toggle. 6 + * 7 + * Extracted from landing.ts for decomposition. 8 + */ 9 + 10 + import type { DocumentMeta, Folder, FolderAssignments, StarMap, SortLabels } from './landing-types.js'; 11 + import { 12 + toggleStar, 13 + createFolder, 14 + renameFolder, 15 + deleteFolder, 16 + moveToFolder, 17 + clearFolderAssignments, 18 + generateRandomUsername, 19 + validateUsername, 20 + trackRecentDoc, 21 + } from './landing-utils.js'; 22 + import { parseTags, saveDocumentTags } from './tags.js'; 23 + import { showToast } from './landing-toast.js'; 24 + import { showMoveModal as renderMoveModal } from './landing-render.js'; 25 + import type { RenderDeps } from './landing-render.js'; 26 + 27 + // ── Deps interface ─────────────────────────────────────────── 28 + 29 + export interface EventDeps { 30 + // DOM elements 31 + docListEl: HTMLElement; 32 + folderListEl: HTMLElement; 33 + trashListEl: HTMLElement; 34 + breadcrumbsEl: HTMLElement; 35 + moveFolderList: HTMLElement; 36 + moveModal: HTMLElement; 37 + searchInput: HTMLInputElement; 38 + searchClear: HTMLElement; 39 + sortBtn: HTMLElement; 40 + sortLabel: HTMLElement; 41 + sortMenu: HTMLElement; 42 + newFolderBtn: HTMLElement; 43 + viewToggleBtn: HTMLElement | null; 44 + trashToggle: HTMLElement; 45 + usernameModal: HTMLElement; 46 + usernameInput: HTMLInputElement; 47 + usernameSkip: HTMLElement; 48 + usernameConfirm: HTMLElement; 49 + folderModal: HTMLElement; 50 + folderModalTitle: HTMLElement; 51 + folderNameInput: HTMLInputElement; 52 + folderCancel: HTMLElement; 53 + folderConfirm: HTMLElement; 54 + moveCancel: HTMLElement; 55 + userBadge: HTMLElement; 56 + backupExportBtn: HTMLElement; 57 + backupImportBtn: HTMLElement; 58 + backupImportInput: HTMLInputElement; 59 + 60 + // State accessors 61 + getAllDocs: () => DocumentMeta[]; 62 + setAllDocs: (docs: DocumentMeta[]) => void; 63 + getTrashedDocs: () => DocumentMeta[]; 64 + setTrashedDocs: (docs: DocumentMeta[]) => void; 65 + getStars: () => StarMap; 66 + setStars: (s: StarMap) => void; 67 + getFolders: () => Folder[]; 68 + setFolders: (f: Folder[]) => void; 69 + getFolderAssignments: () => FolderAssignments; 70 + setFolderAssignments: (a: FolderAssignments) => void; 71 + getCurrentFolderId: () => string | null; 72 + setCurrentFolderId: (id: string | null) => void; 73 + getRecentIds: () => string[]; 74 + setRecentIds: (ids: string[]) => void; 75 + getSearchQuery: () => string; 76 + setSearchQuery: (q: string) => void; 77 + getActiveTagFilter: () => string | null; 78 + setActiveTagFilter: (t: string | null) => void; 79 + getTrashExpanded: () => boolean; 80 + setTrashExpanded: (v: boolean) => void; 81 + getViewMode: () => 'list' | 'grid'; 82 + setViewMode: (m: 'list' | 'grid') => void; 83 + getCurrentSort: () => string; 84 + setCurrentSort: (s: string) => void; 85 + getMoveDocId: () => string | null; 86 + setMoveDocId: (id: string | null) => void; 87 + 88 + // Callbacks 89 + renderDocuments: () => void; 90 + loadDocuments: () => Promise<void>; 91 + getRenderDeps: () => RenderDeps; 92 + } 93 + 94 + // ── Sort labels ────────────────────────────────────────────── 95 + 96 + const SORT_LABELS: SortLabels = { 97 + updated: 'Last updated', 98 + created: 'Created', 99 + name: 'Name', 100 + type: 'Type', 101 + }; 102 + 103 + // ── Username / identity ────────────────────────────────────── 104 + 105 + interface TsIdentity { login: string; name: string; profilePic: string | null; } 106 + let tsIdentity: TsIdentity | null = null; 107 + 108 + function showUserBadge(deps: EventDeps, name: string, profilePic?: string | null): void { 109 + if (profilePic) { 110 + deps.userBadge.innerHTML = `<img src="${profilePic}" alt="" class="user-avatar" />${name}`; 111 + } else { 112 + deps.userBadge.textContent = name; 113 + } 114 + deps.userBadge.title = tsIdentity ? `${tsIdentity.name} (${tsIdentity.login})` : 'Click to change name'; 115 + deps.userBadge.style.display = ''; 116 + } 117 + 118 + function saveUsername(deps: EventDeps, name: string): void { 119 + localStorage.setItem('tools-username', name); 120 + deps.usernameModal.style.display = 'none'; 121 + showUserBadge(deps, name, null); 122 + } 123 + 124 + export async function initUsername(deps: EventDeps): Promise<void> { 125 + // Try Tailscale identity first (injected by Tailscale Serve) 126 + try { 127 + const res = await fetch('/api/me'); 128 + const data = await res.json(); 129 + if (data.login) { 130 + tsIdentity = data as TsIdentity; 131 + localStorage.setItem('tools-username', tsIdentity.name); 132 + showUserBadge(deps, tsIdentity.name, tsIdentity.profilePic); 133 + return; 134 + } 135 + } catch { /* anonymous/local access — fall through */ } 136 + 137 + // Fall back to localStorage username 138 + const existing = localStorage.getItem('tools-username'); 139 + if (existing) { 140 + showUserBadge(deps, existing, null); 141 + return; 142 + } 143 + deps.usernameModal.style.display = ''; 144 + deps.usernameInput.focus(); 145 + } 146 + 147 + // ── Delegated listeners (attached once) ────────────────────── 148 + 149 + export function setupDelegatedListeners(deps: EventDeps): void { 150 + // --- docListEl: star, delete, move, duplicate, tag-edit, recent tracking --- 151 + deps.docListEl.addEventListener('click', async (e) => { 152 + const target = e.target as HTMLElement; 153 + 154 + // Star toggle 155 + const starBtn = target.closest('.doc-star') as HTMLElement | null; 156 + if (starBtn) { 157 + e.preventDefault(); 158 + e.stopPropagation(); 159 + const updated = toggleStar(deps.getStars(), starBtn.dataset.id!); 160 + deps.setStars(updated); 161 + localStorage.setItem('tools-stars', JSON.stringify(updated)); 162 + deps.renderDocuments(); 163 + return; 164 + } 165 + 166 + // Delete (trash) 167 + const deleteBtn = target.closest('.doc-item-delete') as HTMLElement | null; 168 + if (deleteBtn) { 169 + e.preventDefault(); 170 + e.stopPropagation(); 171 + const id = deleteBtn.dataset.id!; 172 + const trashRes = await fetch(`/api/documents/${id}/trash`, { method: 'PUT' }); 173 + if (!trashRes.ok) { showToast('Failed to trash document', 4000, true); return; } 174 + const doc = deps.getAllDocs().find(d => d.id === id); 175 + if (doc) { 176 + doc.deleted_at = new Date().toISOString(); 177 + deps.setAllDocs(deps.getAllDocs().filter(d => d.id !== id)); 178 + deps.setTrashedDocs([doc, ...deps.getTrashedDocs()]); 179 + } 180 + deps.renderDocuments(); 181 + showToast('Document moved to trash', 5000, false, async () => { 182 + await fetch(`/api/documents/${id}/restore`, { method: 'PUT' }); 183 + if (doc) { 184 + doc.deleted_at = null; 185 + deps.setTrashedDocs(deps.getTrashedDocs().filter(d => d.id !== id)); 186 + deps.setAllDocs([...deps.getAllDocs(), doc]); 187 + } 188 + deps.renderDocuments(); 189 + }); 190 + return; 191 + } 192 + 193 + // Move to folder 194 + const moveBtn = target.closest('.doc-item-move') as HTMLElement | null; 195 + if (moveBtn) { 196 + e.preventDefault(); 197 + e.stopPropagation(); 198 + renderMoveModal(deps.getRenderDeps(), moveBtn.dataset.id!); 199 + return; 200 + } 201 + 202 + // Duplicate 203 + const dupBtn = target.closest('.doc-item-duplicate') as HTMLElement | null; 204 + if (dupBtn) { 205 + e.preventDefault(); 206 + e.stopPropagation(); 207 + const id = dupBtn.dataset.id!; 208 + const originalDoc = deps.getAllDocs().find(d => d.id === id); 209 + if (!originalDoc) return; 210 + try { 211 + const res = await fetch('/api/documents', { 212 + method: 'POST', 213 + headers: { 'Content-Type': 'application/json' }, 214 + body: JSON.stringify({ type: originalDoc.type, name_encrypted: originalDoc.name_encrypted }), 215 + }); 216 + if (!res.ok) throw new Error('Create failed'); 217 + const { id: newId } = await res.json(); 218 + const snapRes = await fetch(`/api/documents/${id}/snapshot`); 219 + if (snapRes.ok) { 220 + const blob = await snapRes.blob(); 221 + await fetch(`/api/documents/${newId}/snapshot`, { method: 'PUT', body: blob }); 222 + } 223 + const keys = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 224 + if (keys[id]) { 225 + keys[newId] = keys[id]; 226 + localStorage.setItem('tools-keys', JSON.stringify(keys)); 227 + } 228 + const currentFolderId = deps.getCurrentFolderId(); 229 + if (currentFolderId) { 230 + const updated = moveToFolder(deps.getFolderAssignments(), newId, currentFolderId); 231 + deps.setFolderAssignments(updated); 232 + localStorage.setItem('tools-folder-assignments', JSON.stringify(updated)); 233 + } 234 + await deps.loadDocuments(); 235 + showToast('Document duplicated'); 236 + } catch { 237 + showToast('Failed to duplicate document', 4000, true); 238 + } 239 + return; 240 + } 241 + 242 + // Tag edit 243 + const tagBtn = target.closest('.doc-item-tag-edit') as HTMLElement | null; 244 + if (tagBtn) { 245 + e.preventDefault(); 246 + e.stopPropagation(); 247 + const id = tagBtn.dataset.id!; 248 + const doc = deps.getAllDocs().find(d => d.id === id); 249 + if (!doc) return; 250 + const current = parseTags(doc.tags); 251 + const input = prompt('Tags (comma-separated):', current.join(', ')); 252 + if (input === null) return; 253 + const newTags = input.split(',').map(t => t.trim()).filter(Boolean); 254 + doc.tags = JSON.stringify(newTags); 255 + if (id) saveDocumentTags(id, newTags); 256 + deps.renderDocuments(); 257 + return; 258 + } 259 + 260 + // Track recent docs on click (links that navigate away) 261 + const docLink = target.closest('a[data-doc-id]') as HTMLElement | null; 262 + if (docLink) { 263 + const docId = docLink.dataset.docId; 264 + if (docId) { 265 + const updated = trackRecentDoc(deps.getRecentIds(), docId); 266 + deps.setRecentIds(updated); 267 + localStorage.setItem('tools-recent', JSON.stringify(updated)); 268 + } 269 + } 270 + }); 271 + 272 + // --- folderListEl: navigate, rename, delete --- 273 + deps.folderListEl.addEventListener('click', (e) => { 274 + const target = e.target as HTMLElement; 275 + 276 + // Rename folder 277 + const renameBtn = target.closest('.folder-rename') as HTMLElement | null; 278 + if (renameBtn) { 279 + e.stopPropagation(); 280 + const folder = deps.getFolders().find(f => f.id === renameBtn.dataset.id); 281 + if (!folder) return; 282 + folderModalState.mode = 'rename'; 283 + folderModalState.targetId = folder.id; 284 + deps.folderModalTitle.textContent = 'Rename Folder'; 285 + deps.folderNameInput.value = folder.name; 286 + deps.folderConfirm.textContent = 'Rename'; 287 + deps.folderModal.style.display = ''; 288 + deps.folderNameInput.focus(); 289 + deps.folderNameInput.select(); 290 + return; 291 + } 292 + 293 + // Delete folder 294 + const deleteBtn = target.closest('.folder-delete') as HTMLElement | null; 295 + if (deleteBtn) { 296 + e.stopPropagation(); 297 + const folder = deps.getFolders().find(f => f.id === deleteBtn.dataset.id); 298 + if (!folder) return; 299 + if (!confirm(`Delete folder "${folder.name}"? Documents inside will be moved to the root.`)) return; 300 + const clearedAssignments = clearFolderAssignments(deps.getFolderAssignments(), folder.id); 301 + deps.setFolderAssignments(clearedAssignments); 302 + const updatedFolders = deleteFolder(deps.getFolders(), folder.id); 303 + deps.setFolders(updatedFolders); 304 + localStorage.setItem('tools-folders', JSON.stringify(updatedFolders)); 305 + localStorage.setItem('tools-folder-assignments', JSON.stringify(clearedAssignments)); 306 + deps.renderDocuments(); 307 + return; 308 + } 309 + 310 + // Navigate into folder (but not if clicking actions) 311 + const card = target.closest('.folder-card') as HTMLElement | null; 312 + if (card && !target.closest('.folder-card-actions')) { 313 + deps.setCurrentFolderId(card.dataset.folderId!); 314 + deps.renderDocuments(); 315 + } 316 + }); 317 + 318 + // --- trashListEl: restore, permanent delete, empty all --- 319 + deps.trashListEl.addEventListener('click', async (e) => { 320 + const target = e.target as HTMLElement; 321 + 322 + // Restore 323 + const restoreBtn = target.closest('.trash-restore') as HTMLElement | null; 324 + if (restoreBtn) { 325 + const id = restoreBtn.dataset.id!; 326 + await fetch(`/api/documents/${id}/restore`, { method: 'PUT' }); 327 + const doc = deps.getTrashedDocs().find(d => d.id === id); 328 + if (doc) { 329 + doc.deleted_at = null; 330 + deps.setTrashedDocs(deps.getTrashedDocs().filter(d => d.id !== id)); 331 + deps.setAllDocs([...deps.getAllDocs(), doc]); 332 + } 333 + deps.renderDocuments(); 334 + return; 335 + } 336 + 337 + // Permanent delete 338 + const permBtn = target.closest('.trash-permanent') as HTMLElement | null; 339 + if (permBtn) { 340 + const doc = deps.getTrashedDocs().find(d => d.id === permBtn.dataset.id); 341 + const name = doc?._decryptedName || 'this document'; 342 + if (!confirm(`Permanently delete "${name}"? This cannot be undone.`)) return; 343 + const id = permBtn.dataset.id!; 344 + await fetch(`/api/documents/${id}`, { method: 'DELETE' }); 345 + const k = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 346 + delete k[id]; 347 + localStorage.setItem('tools-keys', JSON.stringify(k)); 348 + deps.setTrashedDocs(deps.getTrashedDocs().filter(d => d.id !== id)); 349 + deps.renderDocuments(); 350 + return; 351 + } 352 + 353 + // Empty all trash 354 + const emptyBtn = target.closest('.trash-empty-all') as HTMLElement | null; 355 + if (emptyBtn) { 356 + const docs = deps.getTrashedDocs(); 357 + if (!confirm(`Permanently delete all ${docs.length} trashed documents? This cannot be undone.`)) return; 358 + const k = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 359 + for (const doc of docs) { 360 + await fetch(`/api/documents/${doc.id}`, { method: 'DELETE' }).catch(() => showToast('Failed to delete document from server', 4000, true)); 361 + delete k[doc.id]; 362 + } 363 + localStorage.setItem('tools-keys', JSON.stringify(k)); 364 + deps.setTrashedDocs([]); 365 + deps.renderDocuments(); 366 + } 367 + }); 368 + 369 + // --- breadcrumbsEl: navigate --- 370 + deps.breadcrumbsEl.addEventListener('click', (e) => { 371 + const btn = (e.target as HTMLElement).closest('.breadcrumb-link') as HTMLElement | null; 372 + if (btn) { 373 + deps.setCurrentFolderId(btn.dataset.folderId || null); 374 + deps.renderDocuments(); 375 + } 376 + }); 377 + 378 + // --- moveFolderList: select folder --- 379 + deps.moveFolderList.addEventListener('click', (e) => { 380 + const btn = (e.target as HTMLElement).closest('.move-option') as HTMLElement | null; 381 + if (btn) { 382 + const fid = btn.dataset.folderId || null; 383 + const updated = moveToFolder(deps.getFolderAssignments(), deps.getMoveDocId()!, fid); 384 + deps.setFolderAssignments(updated); 385 + localStorage.setItem('tools-folder-assignments', JSON.stringify(updated)); 386 + deps.moveModal.style.display = 'none'; 387 + deps.setMoveDocId(null); 388 + deps.renderDocuments(); 389 + } 390 + }); 391 + 392 + // --- Tag filter bar (delegated on parent since tagBarEl may not exist yet) --- 393 + const tagParent = deps.docListEl.parentElement; 394 + if (tagParent) { 395 + tagParent.addEventListener('click', (e) => { 396 + const btn = (e.target as HTMLElement).closest('.tag-filter-pill') as HTMLElement | null; 397 + if (btn) { 398 + const tag = btn.dataset.tag || null; 399 + deps.setActiveTagFilter(tag || null); 400 + deps.renderDocuments(); 401 + } 402 + }); 403 + } 404 + 405 + // --- Recent section (delegated on parent) --- 406 + const recentEl = document.getElementById('recent-section'); 407 + if (recentEl) { 408 + recentEl.addEventListener('click', (e) => { 409 + const link = (e.target as HTMLElement).closest('a.recent-card[data-doc-id]') as HTMLElement | null; 410 + if (link) { 411 + const docId = link.dataset.docId; 412 + if (docId) { 413 + const updated = trackRecentDoc(deps.getRecentIds(), docId); 414 + deps.setRecentIds(updated); 415 + localStorage.setItem('tools-recent', JSON.stringify(updated)); 416 + } 417 + } 418 + }); 419 + } 420 + } 421 + 422 + // ── Folder modal state (module-scoped) ─────────────────────── 423 + 424 + const folderModalState = { 425 + mode: 'create' as 'create' | 'rename', 426 + targetId: null as string | null, 427 + }; 428 + 429 + // ── One-off listeners ──────────────────────────────────────── 430 + 431 + export function setupOneOffListeners(deps: EventDeps): void { 432 + // --- Sort --- 433 + deps.sortLabel.textContent = SORT_LABELS[deps.getCurrentSort()] || SORT_LABELS.updated; 434 + 435 + deps.sortBtn.addEventListener('click', (e) => { 436 + e.stopPropagation(); 437 + deps.sortMenu.classList.toggle('open'); 438 + }); 439 + 440 + deps.sortMenu.addEventListener('click', (e) => { 441 + const btn = (e.target as HTMLElement).closest('.sort-option') as HTMLElement | null; 442 + if (!btn) return; 443 + const sort = btn.dataset.sort!; 444 + deps.setCurrentSort(sort); 445 + localStorage.setItem('tools-sort', sort); 446 + deps.sortLabel.textContent = SORT_LABELS[sort]; 447 + deps.sortMenu.classList.remove('open'); 448 + deps.renderDocuments(); 449 + }); 450 + 451 + document.addEventListener('click', () => { 452 + deps.sortMenu.classList.remove('open'); 453 + }); 454 + 455 + // --- Search --- 456 + deps.searchInput.addEventListener('input', () => { 457 + deps.setSearchQuery(deps.searchInput.value); 458 + deps.searchClear.style.display = deps.searchInput.value ? '' : 'none'; 459 + deps.renderDocuments(); 460 + }); 461 + 462 + deps.searchClear.addEventListener('click', () => { 463 + deps.searchInput.value = ''; 464 + deps.setSearchQuery(''); 465 + deps.searchClear.style.display = 'none'; 466 + deps.renderDocuments(); 467 + }); 468 + 469 + // Init clear button visibility 470 + deps.searchClear.style.display = 'none'; 471 + 472 + // --- New folder --- 473 + deps.newFolderBtn.addEventListener('click', () => { 474 + folderModalState.mode = 'create'; 475 + folderModalState.targetId = null; 476 + deps.folderModalTitle.textContent = 'New Folder'; 477 + deps.folderNameInput.value = ''; 478 + deps.folderConfirm.textContent = 'Create'; 479 + deps.folderModal.style.display = ''; 480 + deps.folderNameInput.focus(); 481 + }); 482 + 483 + deps.folderCancel.addEventListener('click', () => { 484 + deps.folderModal.style.display = 'none'; 485 + }); 486 + 487 + deps.folderConfirm.addEventListener('click', () => { 488 + const name = deps.folderNameInput.value.trim(); 489 + if (!name) return; 490 + 491 + if (folderModalState.mode === 'create') { 492 + deps.setFolders(createFolder(deps.getFolders(), name)); 493 + } else if (folderModalState.mode === 'rename') { 494 + deps.setFolders(renameFolder(deps.getFolders(), folderModalState.targetId!, name)); 495 + } 496 + localStorage.setItem('tools-folders', JSON.stringify(deps.getFolders())); 497 + deps.folderModal.style.display = 'none'; 498 + deps.renderDocuments(); 499 + }); 500 + 501 + deps.folderNameInput.addEventListener('keydown', (e) => { 502 + if (e.key === 'Enter') deps.folderConfirm.click(); 503 + if (e.key === 'Escape') deps.folderCancel.click(); 504 + }); 505 + 506 + deps.moveCancel.addEventListener('click', () => { 507 + deps.moveModal.style.display = 'none'; 508 + deps.setMoveDocId(null); 509 + }); 510 + 511 + // --- Trash toggle --- 512 + deps.trashToggle.addEventListener('click', () => { 513 + deps.setTrashExpanded(!deps.getTrashExpanded()); 514 + const icon = deps.trashToggle.querySelector('.trash-toggle-icon'); 515 + if (icon) icon.textContent = deps.getTrashExpanded() ? '\u25BC' : '\u25B6'; 516 + deps.renderDocuments(); 517 + }); 518 + 519 + // --- View toggle --- 520 + if (deps.viewToggleBtn) { 521 + deps.viewToggleBtn.addEventListener('click', () => { 522 + const next = deps.getViewMode() === 'list' ? 'grid' : 'list'; 523 + deps.setViewMode(next); 524 + localStorage.setItem('tools-view-mode', next); 525 + deps.renderDocuments(); 526 + }); 527 + } 528 + 529 + // --- Username --- 530 + deps.usernameConfirm.addEventListener('click', () => { 531 + const val = deps.usernameInput.value.trim(); 532 + const result = validateUsername(val); 533 + if (result.valid) { 534 + saveUsername(deps, val); 535 + } else { 536 + deps.usernameInput.setCustomValidity(result.error!); 537 + deps.usernameInput.reportValidity(); 538 + } 539 + }); 540 + 541 + deps.usernameSkip.addEventListener('click', () => { 542 + saveUsername(deps, generateRandomUsername()); 543 + }); 544 + 545 + deps.usernameInput.addEventListener('keydown', (e) => { 546 + if (e.key === 'Enter') deps.usernameConfirm.click(); 547 + }); 548 + 549 + deps.userBadge.addEventListener('click', () => { 550 + if (tsIdentity) { 551 + alert(`Signed in as ${tsIdentity.name}\n${tsIdentity.login}`); 552 + return; 553 + } 554 + const current = localStorage.getItem('tools-username') || ''; 555 + const newName = prompt('Change your display name:', current); 556 + if (newName !== null) { 557 + const trimmed = newName.trim(); 558 + const result = validateUsername(trimmed); 559 + if (result.valid) { 560 + localStorage.setItem('tools-username', trimmed); 561 + showUserBadge(deps, trimmed, null); 562 + } 563 + } 564 + }); 565 + 566 + // --- Close modals on backdrop click --- 567 + [deps.usernameModal, deps.folderModal, deps.moveModal].forEach(modal => { 568 + modal.addEventListener('click', (e) => { 569 + if (e.target === modal) { 570 + modal.style.display = 'none'; 571 + } 572 + }); 573 + }); 574 + 575 + // --- Backup export/import --- 576 + deps.backupExportBtn.addEventListener('click', async () => { 577 + const docsToExport = deps.getAllDocs().filter(d => !d.deleted_at && d._keyStr); 578 + if (docsToExport.length === 0) { 579 + showToast('No documents to export', 3000, true); 580 + return; 581 + } 582 + showToast(`Exporting ${docsToExport.length} document(s)...`); 583 + const { exportBackup } = await import('./backup.js'); 584 + await exportBackup(docsToExport); 585 + showToast(`Backup exported (${docsToExport.length} documents)`); 586 + }); 587 + 588 + deps.backupImportBtn.addEventListener('click', () => { deps.backupImportInput.click(); }); 589 + deps.backupImportInput.addEventListener('change', async () => { 590 + const file = deps.backupImportInput.files?.[0]; 591 + if (!file) return; 592 + deps.backupImportInput.value = ''; 593 + 594 + const json = await file.text(); 595 + const keys = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 596 + const { importBackup } = await import('./backup.js'); 597 + const result = await importBackup(json, keys); 598 + 599 + if (result.imported === 0 && result.skipped === 0) { 600 + showToast('Invalid backup file', 4000, true); 601 + } else { 602 + showToast(`Restored ${result.imported} document(s)` + (result.skipped ? `, ${result.skipped} skipped` : '')); 603 + deps.loadDocuments(); 604 + } 605 + }); 606 + }
+151
src/landing-import.ts
··· 1 + /** 2 + * File import via drag-and-drop and file picker on the landing page. 3 + * Handles reading files, creating encrypted documents, and navigating 4 + * to the editor with pending import data. 5 + * 6 + * Extracted from landing.ts for decomposition. 7 + */ 8 + 9 + import type { FolderAssignments } from './landing-types.js'; 10 + import { generateKey, exportKey } from './lib/crypto.js'; 11 + import { moveToFolder } from './landing-utils.js'; 12 + import { 13 + getFileType, 14 + getImportType, 15 + pendingImportKey, 16 + buildEditorUrl, 17 + } from './landing-dragdrop.js'; 18 + import { showToast } from './landing-toast.js'; 19 + 20 + // ── Deps interface ─────────────────────────────────────────── 21 + 22 + export interface ImportDeps { 23 + getCurrentFolderId: () => string | null; 24 + getFolderAssignments: () => FolderAssignments; 25 + setFolderAssignments: (a: FolderAssignments) => void; 26 + } 27 + 28 + // ── Import a single file ──────────────────────────────────── 29 + 30 + export async function importFile(deps: ImportDeps, file: File): Promise<void> { 31 + const docType = getFileType(file.name); 32 + const importType = getImportType(file.name); 33 + 34 + if (!docType || !importType) { 35 + showToast(`Unsupported file type: .${file.name.split('.').pop()}`, 4000, true); 36 + return; 37 + } 38 + 39 + try { 40 + // Generate encryption key 41 + const key = await generateKey(); 42 + const keyStr = await exportKey(key); 43 + 44 + // Use filename (without extension) as the document name 45 + const fileBaseName = file.name.replace(/\.[^.]+$/, ''); 46 + const defaultName = fileBaseName || (docType === 'doc' ? 'Untitled Document' : 'Untitled Spreadsheet'); 47 + const nameBytes = new TextEncoder().encode(defaultName); 48 + const { encrypt } = await import('./lib/crypto.js'); 49 + const encryptedName = await encrypt(nameBytes, key); 50 + const nameB64 = btoa(String.fromCharCode(...encryptedName)); 51 + 52 + // Create document via API 53 + const res = await fetch('/api/documents', { 54 + method: 'POST', 55 + headers: { 'Content-Type': 'application/json' }, 56 + body: JSON.stringify({ type: docType, name_encrypted: nameB64 }), 57 + }); 58 + if (!res.ok) { showToast('Failed to create document', 4000, true); return; } 59 + const { id } = await res.json(); 60 + 61 + // Store encryption key 62 + const keys = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 63 + keys[id] = keyStr; 64 + localStorage.setItem('tools-keys', JSON.stringify(keys)); 65 + 66 + // If inside a folder, assign to it 67 + const currentFolderId = deps.getCurrentFolderId(); 68 + if (currentFolderId) { 69 + const updated = moveToFolder(deps.getFolderAssignments(), id, currentFolderId); 70 + deps.setFolderAssignments(updated); 71 + localStorage.setItem('tools-folder-assignments', JSON.stringify(updated)); 72 + } 73 + 74 + // Read file and store in sessionStorage for the editor to pick up 75 + const reader = new FileReader(); 76 + reader.onload = () => { 77 + const payload = JSON.stringify({ 78 + name: file.name, 79 + type: importType, 80 + data: reader.result, // data URL (base64-encoded) 81 + }); 82 + sessionStorage.setItem(pendingImportKey(id), payload); 83 + 84 + // Navigate to the editor 85 + window.location.href = buildEditorUrl(docType, id, keyStr); 86 + }; 87 + reader.onerror = () => { 88 + showToast('Failed to read file', 4000, true); 89 + }; 90 + reader.readAsDataURL(file); 91 + } catch (err) { 92 + showToast('Failed to create document for import', 4000, true); 93 + } 94 + } 95 + 96 + // ── Drag-and-drop setup ───────────────────────────────────── 97 + 98 + export function setupDragAndDrop(deps: ImportDeps): void { 99 + const dropOverlay = document.getElementById('drop-overlay'); 100 + let dragCounter = 0; 101 + 102 + function showDropOverlay() { 103 + if (dropOverlay) dropOverlay.style.display = ''; 104 + } 105 + 106 + function hideDropOverlay() { 107 + if (dropOverlay) dropOverlay.style.display = 'none'; 108 + } 109 + 110 + document.addEventListener('dragenter', (e) => { 111 + e.preventDefault(); 112 + dragCounter++; 113 + if (dragCounter === 1) showDropOverlay(); 114 + }); 115 + 116 + document.addEventListener('dragover', (e) => { 117 + e.preventDefault(); 118 + if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; 119 + }); 120 + 121 + document.addEventListener('dragleave', (e) => { 122 + e.preventDefault(); 123 + dragCounter--; 124 + if (dragCounter <= 0) { 125 + dragCounter = 0; 126 + hideDropOverlay(); 127 + } 128 + }); 129 + 130 + document.addEventListener('drop', async (e) => { 131 + e.preventDefault(); 132 + dragCounter = 0; 133 + hideDropOverlay(); 134 + 135 + const file = e.dataTransfer?.files[0]; 136 + if (!file) return; 137 + importFile(deps, file); 138 + }); 139 + 140 + // File import button (mobile-friendly alternative to drag-drop) 141 + const fileImportBtn = document.getElementById('file-import-btn'); 142 + const fileImportInput = document.getElementById('file-import-input') as HTMLInputElement | null; 143 + if (fileImportBtn && fileImportInput) { 144 + fileImportBtn.addEventListener('click', () => fileImportInput.click()); 145 + fileImportInput.addEventListener('change', () => { 146 + const file = fileImportInput.files?.[0]; 147 + if (file) importFile(deps, file); 148 + fileImportInput.value = ''; 149 + }); 150 + } 151 + }
+405
src/landing-render.ts
··· 1 + /** 2 + * Rendering functions for the landing page: document list, folders, 3 + * breadcrumbs, recent/pinned sections, trash, tag filter bar, move modal. 4 + * 5 + * Extracted from landing.ts for decomposition. 6 + */ 7 + 8 + import type { DocumentMeta, Folder, FolderAssignments, StarMap } from './landing-types.js'; 9 + import { 10 + sortDocuments, 11 + starredIdsSet, 12 + filterBySearch, 13 + getDocsInFolder, 14 + buildBreadcrumbs, 15 + getRecentDocs, 16 + } from './landing-utils.js'; 17 + import { parseTags, collectAllTags, filterByTag } from './tags.js'; 18 + 19 + // --- Document type routing --- 20 + const DOC_PATH_MAP: Record<string, string> = { doc: '/docs', sheet: '/sheets', form: '/forms', slide: '/slides', diagram: '/diagrams' }; 21 + const DOC_ICON_MAP: Record<string, string> = { doc: '&#9998;', sheet: '&#9638;', form: '&#9783;', slide: '&#9707;', diagram: '&#9683;' }; 22 + 23 + export function docPath(type: string): string { return DOC_PATH_MAP[type] || '/sheets'; } 24 + export function docIcon(type: string): string { return DOC_ICON_MAP[type] || '&#9638;'; } 25 + 26 + export function escapeHtml(text: string): string { 27 + const div = document.createElement('div'); 28 + div.textContent = text; 29 + return div.innerHTML; 30 + } 31 + 32 + // ── Deps interface ─────────────────────────────────────────── 33 + 34 + export interface RenderDeps { 35 + docListEl: HTMLElement; 36 + folderListEl: HTMLElement; 37 + noResultsEl: HTMLElement; 38 + breadcrumbsEl: HTMLElement; 39 + trashSection: HTMLElement; 40 + trashCount: HTMLElement; 41 + trashListEl: HTMLElement; 42 + viewToggleBtn: HTMLElement | null; 43 + moveFolderList: HTMLElement; 44 + moveModal: HTMLElement; 45 + 46 + getAllDocs: () => DocumentMeta[]; 47 + getTrashedDocs: () => DocumentMeta[]; 48 + getStars: () => StarMap; 49 + getFolders: () => Folder[]; 50 + getFolderAssignments: () => FolderAssignments; 51 + getCurrentFolderId: () => string | null; 52 + getSearchQuery: () => string; 53 + getActiveTagFilter: () => string | null; 54 + getCurrentSort: () => string; 55 + getViewMode: () => 'list' | 'grid'; 56 + getTrashExpanded: () => boolean; 57 + getRecentIds: () => string[]; 58 + 59 + setMoveDocId: (id: string) => void; 60 + } 61 + 62 + // ── Recent section ─────────────────────────────────────────── 63 + 64 + export function renderRecentSection(deps: RenderDeps, keys: Record<string, string>): void { 65 + const recentEl = document.getElementById('recent-section'); 66 + if (!recentEl) return; 67 + 68 + const recent = getRecentDocs(deps.getRecentIds(), deps.getAllDocs(), keys); 69 + if (recent.length === 0) { 70 + recentEl.innerHTML = ''; 71 + return; 72 + } 73 + 74 + let html = '<h3 class="recent-heading">Recent</h3><div class="recent-list">'; 75 + for (const doc of recent) { 76 + const path = docPath(doc.type); 77 + const icon = docIcon(doc.type); 78 + const name = doc._decryptedName || 'Encrypted Document'; 79 + const href = doc._keyStr ? `${path}/${doc.id}#${doc._keyStr}` : '#'; 80 + html += `<a class="recent-card" href="${href}" data-doc-id="${doc.id}"> 81 + <span class="recent-card-icon">${icon}</span> 82 + <span class="recent-card-name">${escapeHtml(name)}</span> 83 + <span class="recent-card-type">${doc.type}</span> 84 + </a>`; 85 + } 86 + html += '</div>'; 87 + recentEl.innerHTML = html; 88 + } 89 + 90 + // ── Pinned section ─────────────────────────────────────────── 91 + 92 + export function renderPinnedSection(deps: RenderDeps, keys: Record<string, string>): void { 93 + const pinnedEl = document.getElementById('pinned-section'); 94 + if (!pinnedEl) return; 95 + 96 + const starSet = starredIdsSet(deps.getStars()); 97 + if (starSet.size === 0) { 98 + pinnedEl.innerHTML = ''; 99 + return; 100 + } 101 + 102 + const pinned = deps.getAllDocs().filter(d => starSet.has(d.id)); 103 + if (pinned.length === 0) { 104 + pinnedEl.innerHTML = ''; 105 + return; 106 + } 107 + 108 + let html = '<h3 class="pinned-heading">Pinned</h3><div class="pinned-list">'; 109 + for (const doc of pinned) { 110 + const path = docPath(doc.type); 111 + const icon = docIcon(doc.type); 112 + const name = doc._decryptedName || 'Encrypted Document'; 113 + const href = doc._keyStr ? `${path}/${doc.id}#${doc._keyStr}` : '#'; 114 + html += `<a class="pinned-card" href="${href}" data-doc-id="${doc.id}"> 115 + <span class="pinned-card-icon">${icon}</span> 116 + <span class="pinned-card-name">${escapeHtml(name)}</span> 117 + <span class="pinned-card-type">${doc.type}</span> 118 + </a>`; 119 + } 120 + html += '</div>'; 121 + pinnedEl.innerHTML = html; 122 + } 123 + 124 + // ── Tag filter bar ─────────────────────────────────────────── 125 + 126 + export function renderTagFilter(deps: RenderDeps, docs: DocumentMeta[]): void { 127 + let tagBarEl = document.getElementById('tag-filter-bar'); 128 + const allTags = collectAllTags(docs); 129 + if (allTags.length === 0) { 130 + if (tagBarEl) tagBarEl.innerHTML = ''; 131 + return; 132 + } 133 + if (!tagBarEl) { 134 + tagBarEl = document.createElement('div'); 135 + tagBarEl.id = 'tag-filter-bar'; 136 + tagBarEl.className = 'tag-filter-bar'; 137 + const parent = deps.docListEl.parentElement; 138 + if (parent) parent.insertBefore(tagBarEl, deps.docListEl); 139 + } 140 + const activeTagFilter = deps.getActiveTagFilter(); 141 + let html = '<span class="tag-filter-label">Tags:</span>'; 142 + html += `<button class="tag-filter-pill${!activeTagFilter ? ' active' : ''}" data-tag="">All</button>`; 143 + for (const tag of allTags) { 144 + const isActive = activeTagFilter === tag; 145 + html += `<button class="tag-filter-pill${isActive ? ' active' : ''}" data-tag="${escapeHtml(tag)}">${escapeHtml(tag)}</button>`; 146 + } 147 + tagBarEl.innerHTML = html; 148 + } 149 + 150 + // ── Breadcrumbs ────────────────────────────────────────────── 151 + 152 + export function renderBreadcrumbs(deps: RenderDeps): void { 153 + const crumbs = buildBreadcrumbs(deps.getFolders(), deps.getCurrentFolderId()); 154 + let html = ''; 155 + crumbs.forEach((crumb, i) => { 156 + if (i > 0) html += ' <span class="breadcrumb-sep">/</span> '; 157 + if (i < crumbs.length - 1) { 158 + html += `<button class="breadcrumb-link" data-folder-id="${crumb.id || ''}">${escapeHtml(crumb.name)}</button>`; 159 + } else { 160 + html += `<span class="breadcrumb-current">${escapeHtml(crumb.name)}</span>`; 161 + } 162 + }); 163 + deps.breadcrumbsEl.innerHTML = html; 164 + } 165 + 166 + // ── Folders ────────────────────────────────────────────────── 167 + 168 + export function renderFolders(deps: RenderDeps, activeDocs: DocumentMeta[]): void { 169 + const currentFolderId = deps.getCurrentFolderId(); 170 + const searchQuery = deps.getSearchQuery(); 171 + const folders = deps.getFolders(); 172 + const folderAssignments = deps.getFolderAssignments(); 173 + 174 + // Only show folders at root level and when not searching 175 + if (currentFolderId !== null || searchQuery) { 176 + deps.folderListEl.innerHTML = ''; 177 + return; 178 + } 179 + 180 + if (folders.length === 0) { 181 + deps.folderListEl.innerHTML = ''; 182 + return; 183 + } 184 + 185 + let html = '<div class="folder-grid">'; 186 + for (const folder of folders) { 187 + const docCount = activeDocs.filter(d => folderAssignments[d.id] === folder.id).length; 188 + html += ` 189 + <div class="folder-card" data-folder-id="${folder.id}"> 190 + <span class="folder-card-icon">&#9647;</span> 191 + <span class="folder-card-name">${escapeHtml(folder.name)}</span> 192 + <span class="folder-card-count">${docCount} doc${docCount !== 1 ? 's' : ''}</span> 193 + <div class="folder-card-actions"> 194 + <button class="btn-icon folder-rename" data-id="${folder.id}" title="Rename">&#9998;</button> 195 + <button class="btn-icon folder-delete" data-id="${folder.id}" title="Delete folder">&#10005;</button> 196 + </div> 197 + </div>`; 198 + } 199 + html += '</div>'; 200 + deps.folderListEl.innerHTML = html; 201 + } 202 + 203 + // ── Trash ──────────────────────────────────────────────────── 204 + 205 + export function renderTrash(deps: RenderDeps, docs: DocumentMeta[], keys: Record<string, string>): void { 206 + if (docs.length === 0) { 207 + deps.trashSection.style.display = 'none'; 208 + return; 209 + } 210 + 211 + deps.trashSection.style.display = ''; 212 + deps.trashCount.textContent = `(${docs.length})`; 213 + 214 + if (!deps.getTrashExpanded()) { 215 + deps.trashListEl.style.display = 'none'; 216 + return; 217 + } 218 + 219 + deps.trashListEl.style.display = ''; 220 + let html = '<div class="trash-actions"><button class="btn-danger btn-sm trash-empty-all">Empty Trash</button></div>'; 221 + html += '<div class="doc-list">'; 222 + for (const doc of docs) { 223 + const name = doc._decryptedName || 'Encrypted Document'; 224 + const icon = doc.type === 'doc' ? '&#9998;' : '&#9638;'; 225 + const deletedDate = doc.deleted_at 226 + ? new Date(doc.deleted_at + 'Z').toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) 227 + : ''; 228 + 229 + html += ` 230 + <div class="doc-item trash-item"> 231 + <span class="doc-item-icon">${icon}</span> 232 + <span class="doc-item-name trash-name">${escapeHtml(name)}</span> 233 + <span class="doc-item-date">Deleted ${deletedDate}</span> 234 + <button class="btn-secondary btn-sm trash-restore" data-id="${doc.id}">Restore</button> 235 + <button class="btn-danger btn-sm trash-permanent" data-id="${doc.id}">Delete permanently</button> 236 + </div>`; 237 + } 238 + html += '</div>'; 239 + deps.trashListEl.innerHTML = html; 240 + } 241 + 242 + // ── Move modal ─────────────────────────────────────────────── 243 + 244 + export function showMoveModal(deps: RenderDeps, docId: string): void { 245 + deps.setMoveDocId(docId); 246 + const folderAssignments = deps.getFolderAssignments(); 247 + const folders = deps.getFolders(); 248 + const currentFolder = folderAssignments[docId] || null; 249 + 250 + let html = ''; 251 + html += `<button class="move-option ${currentFolder === null ? 'active' : ''}" data-folder-id=""> 252 + &#8962; Root (no folder) 253 + </button>`; 254 + for (const folder of folders) { 255 + const active = currentFolder === folder.id ? 'active' : ''; 256 + html += `<button class="move-option ${active}" data-folder-id="${folder.id}"> 257 + &#9647; ${escapeHtml(folder.name)} 258 + </button>`; 259 + } 260 + 261 + if (folders.length === 0) { 262 + html += '<p class="move-empty">No folders yet. Create one first.</p>'; 263 + } 264 + 265 + deps.moveFolderList.innerHTML = html; 266 + deps.moveModal.style.display = ''; 267 + } 268 + 269 + // ── View toggle ────────────────────────────────────────────── 270 + 271 + export function updateViewToggle(deps: RenderDeps): void { 272 + if (!deps.viewToggleBtn) return; 273 + const viewMode = deps.getViewMode(); 274 + const gridIcon = deps.viewToggleBtn.querySelector('.view-icon-grid') as HTMLElement | null; 275 + const listIcon = deps.viewToggleBtn.querySelector('.view-icon-list') as HTMLElement | null; 276 + if (gridIcon) gridIcon.style.display = viewMode === 'list' ? '' : 'none'; 277 + if (listIcon) listIcon.style.display = viewMode === 'grid' ? '' : 'none'; 278 + } 279 + 280 + // ── Main render orchestrator ───────────────────────────────── 281 + 282 + export function renderDocuments(deps: RenderDeps): void { 283 + const keys = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 284 + const starSet = starredIdsSet(deps.getStars()); 285 + const currentFolderId = deps.getCurrentFolderId(); 286 + const searchQuery = deps.getSearchQuery(); 287 + const activeTagFilter = deps.getActiveTagFilter(); 288 + const currentSort = deps.getCurrentSort(); 289 + const viewMode = deps.getViewMode(); 290 + 291 + // allDocs already contains only active docs (server filters out trashed) 292 + const active = deps.getAllDocs(); 293 + 294 + // Apply folder filter 295 + let visibleDocs; 296 + if (currentFolderId === null) { 297 + // "All Documents" view — show all active docs (flat) 298 + visibleDocs = active; 299 + } else { 300 + visibleDocs = getDocsInFolder(active, deps.getFolderAssignments(), currentFolderId); 301 + } 302 + 303 + // Apply search filter 304 + visibleDocs = filterBySearch(visibleDocs, searchQuery); 305 + 306 + // Apply tag filter 307 + if (activeTagFilter) { 308 + visibleDocs = filterByTag(visibleDocs, activeTagFilter) as DocumentMeta[]; 309 + } 310 + 311 + // Render tag filter bar 312 + renderTagFilter(deps, active); 313 + 314 + // Apply sort 315 + const sorted = sortDocuments(visibleDocs, currentSort, starSet); 316 + 317 + // Render breadcrumbs 318 + renderBreadcrumbs(deps); 319 + 320 + // Render pinned section 321 + renderPinnedSection(deps, keys); 322 + 323 + // Update view toggle icon state 324 + updateViewToggle(deps); 325 + 326 + // Render folder cards (only at root, not inside a folder, and not when searching) 327 + renderFolders(deps, active); 328 + 329 + // Render document list 330 + const trashedDocs = deps.getTrashedDocs(); 331 + if (sorted.length === 0 && active.length === 0 && trashedDocs.length === 0) { 332 + deps.docListEl.innerHTML = ` 333 + <div class="empty-state"> 334 + <strong>No documents yet</strong> 335 + Create your first encrypted document or spreadsheet above. 336 + </div>`; 337 + deps.noResultsEl.style.display = 'none'; 338 + } else if (sorted.length === 0) { 339 + deps.docListEl.innerHTML = ''; 340 + deps.noResultsEl.style.display = searchQuery ? '' : 'none'; 341 + } else { 342 + deps.noResultsEl.style.display = 'none'; 343 + const isGrid = viewMode === 'grid'; 344 + let html = `<div class="doc-list${isGrid ? ' grid-view' : ''}">`; 345 + for (const doc of sorted) { 346 + const path = docPath(doc.type); 347 + const icon = docIcon(doc.type); 348 + const keyStr = doc._keyStr; 349 + const name = doc._decryptedName || 'Encrypted Document'; 350 + const isStarred = starSet.has(doc.id); 351 + const href = keyStr ? `${path}/${doc.id}#${keyStr}` : '#'; 352 + const date = new Date(doc.updated_at + 'Z').toLocaleDateString(undefined, { 353 + month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', 354 + }); 355 + 356 + const docTags = parseTags(doc.tags); 357 + const tagsHtml = docTags.map(t => `<span class="doc-tag-pill">${escapeHtml(t)}</span>`).join(''); 358 + 359 + if (isGrid) { 360 + html += ` 361 + <a class="doc-grid-card" href="${href}" ${!keyStr ? 'title="Key not available — you need the original link to decrypt"' : ''} data-doc-id="${doc.id}"> 362 + <div class="doc-grid-card-header"> 363 + <span class="doc-item-icon">${icon}</span> 364 + <button class="btn-icon doc-star" data-id="${doc.id}" title="${isStarred ? 'Remove from favorites' : 'Add to favorites'}">${isStarred ? '\u2605' : '\u2606'}</button> 365 + </div> 366 + <span class="doc-grid-card-name">${escapeHtml(name)}</span> 367 + ${tagsHtml ? `<span class="doc-item-tags">${tagsHtml}</span>` : ''} 368 + <div class="doc-grid-card-footer"> 369 + <span class="doc-item-type">${doc.type}</span> 370 + <span class="doc-item-date">${date}</span> 371 + </div> 372 + <div class="doc-grid-card-actions"> 373 + <button class="btn-icon doc-item-tag-edit" data-id="${doc.id}" title="Edit tags">#</button> 374 + <button class="btn-icon doc-item-move" data-id="${doc.id}" title="Move to folder">&#9647;</button> 375 + <button class="btn-icon doc-item-duplicate" data-id="${doc.id}" title="Duplicate">&#10697;</button> 376 + <button class="btn-icon doc-item-delete" data-id="${doc.id}" title="Move to trash">&#10005;</button> 377 + </div> 378 + </a>`; 379 + } else { 380 + html += ` 381 + <a class="doc-item" href="${href}" ${!keyStr ? 'title="Key not available — you need the original link to decrypt"' : ''} data-doc-id="${doc.id}"> 382 + <button class="btn-icon doc-star" data-id="${doc.id}" title="${isStarred ? 'Remove from favorites' : 'Add to favorites'}">${isStarred ? '\u2605' : '\u2606'}</button> 383 + <span class="doc-item-icon">${icon}</span> 384 + <span class="doc-item-name">${escapeHtml(name)}</span> 385 + ${tagsHtml ? `<span class="doc-item-tags">${tagsHtml}</span>` : ''} 386 + ${(doc as any).owner_name ? `<span class="doc-item-owner">${escapeHtml((doc as any).owner_name)}</span>` : ''} 387 + <span class="doc-item-type">${doc.type}</span> 388 + <span class="doc-item-date">${date}</span> 389 + <button class="btn-icon doc-item-tag-edit" data-id="${doc.id}" title="Edit tags">#</button> 390 + <button class="btn-icon doc-item-move" data-id="${doc.id}" title="Move to folder">&#9647;</button> 391 + <button class="btn-icon doc-item-duplicate" data-id="${doc.id}" title="Duplicate">&#10697;</button> 392 + <button class="btn-icon doc-item-delete" data-id="${doc.id}" title="Move to trash">&#10005;</button> 393 + </a>`; 394 + } 395 + } 396 + html += '</div>'; 397 + deps.docListEl.innerHTML = html; 398 + } 399 + 400 + // Render recent documents section 401 + renderRecentSection(deps, keys); 402 + 403 + // Render trash section 404 + renderTrash(deps, trashedDocs, keys); 405 + }
+43
src/landing-toast.ts
··· 1 + /** 2 + * Toast notification system for the landing page. 3 + * Standalone — no external dependencies. 4 + */ 5 + 6 + export function showToast(message: string, duration = 3000, isError = false, onUndo?: () => void): void { 7 + const existing = document.querySelector('.toast-notification'); 8 + if (existing) existing.remove(); 9 + const toast = document.createElement('div'); 10 + toast.className = 'toast-notification' + (isError ? ' toast-error' : ''); 11 + if (onUndo) { 12 + toast.classList.add('toast-interactive'); 13 + const msgSpan = document.createElement('span'); 14 + msgSpan.textContent = message; 15 + toast.appendChild(msgSpan); 16 + const undoBtn = document.createElement('span'); 17 + undoBtn.className = 'toast-undo'; 18 + undoBtn.textContent = 'Undo'; 19 + undoBtn.setAttribute('role', 'button'); 20 + undoBtn.setAttribute('tabindex', '0'); 21 + undoBtn.addEventListener('click', () => { 22 + onUndo(); 23 + toast.classList.remove('toast-visible'); 24 + setTimeout(() => toast.remove(), 300); 25 + }); 26 + undoBtn.addEventListener('keydown', (e) => { 27 + if (e.key === 'Enter' || e.key === ' ') { 28 + e.preventDefault(); 29 + undoBtn.click(); 30 + } 31 + }); 32 + toast.appendChild(undoBtn); 33 + } else { 34 + toast.textContent = message; 35 + } 36 + document.body.appendChild(toast); 37 + toast.offsetHeight; // force reflow 38 + toast.classList.add('toast-visible'); 39 + setTimeout(() => { 40 + toast.classList.remove('toast-visible'); 41 + setTimeout(() => toast.remove(), 300); 42 + }, duration); 43 + }
+123 -1197
src/landing.ts
··· 1 - import type { DocumentMeta, Folder, FolderAssignments, StarMap, SortLabels } from './landing-types.js'; 2 - import { generateKey, exportKey, importKey, decryptString } from './lib/crypto.js'; 3 - import { syncKeys, storeKey, pushKeysToServer } from './lib/key-sync.js'; 1 + /** 2 + * Landing page entry point — wires together state, DOM refs, and 3 + * the extracted modules (render, events, create, import, toast). 4 + */ 5 + 6 + import type { DocumentMeta, Folder, FolderAssignments, StarMap } from './landing-types.js'; 7 + import { importKey, decryptString } from './lib/crypto.js'; 8 + import { syncKeys } from './lib/key-sync.js'; 4 9 import { createCommandPalette, type PaletteAction } from './command-palette.js'; 5 - import { parseTags, addTag, removeTag, saveDocumentTags, collectAllTags, filterByTag } from './tags.js'; 6 - import { formatDailyNoteName, findDailyNote, getDailyNoteTemplate } from './daily-notes.js'; 7 - import { exportBackup, importBackup } from './backup.js'; 8 - import { BUILT_IN_TEMPLATES, getTemplate, type DocTemplate } from './templates.js'; 9 - import { 10 - sortDocuments, 11 - toggleStar, 12 - starredIdsSet, 13 - filterBySearch, 14 - createFolder, 15 - renameFolder, 16 - deleteFolder, 17 - moveToFolder, 18 - getDocsInFolder, 19 - buildBreadcrumbs, 20 - clearFolderAssignments, 21 - generateRandomUsername, 22 - validateUsername, 23 - trackRecentDoc, 24 - getRecentDocs, 25 - DEFAULT_SORT, 26 - } from './landing-utils.js'; 27 - import { 28 - getFileType, 29 - getImportType, 30 - pendingImportKey, 31 - buildEditorUrl, 32 - } from './landing-dragdrop.js'; 33 - 34 - // --- Document type routing --- 35 - const DOC_PATH_MAP: Record<string, string> = { doc: '/docs', sheet: '/sheets', form: '/forms', slide: '/slides', diagram: '/diagrams' }; 36 - const DOC_ICON_MAP: Record<string, string> = { doc: '&#9998;', sheet: '&#9638;', form: '&#9783;', slide: '&#9707;', diagram: '&#9683;' }; 37 - function docPath(type: string): string { return DOC_PATH_MAP[type] || '/sheets'; } 38 - function docIcon(type: string): string { return DOC_ICON_MAP[type] || '&#9638;'; } 10 + import { BUILT_IN_TEMPLATES } from './templates.js'; 11 + import { DEFAULT_SORT } from './landing-utils.js'; 12 + import { showToast } from './landing-toast.js'; 13 + import { renderDocuments as doRender, docPath } from './landing-render.js'; 14 + import type { RenderDeps } from './landing-render.js'; 15 + import { setupDelegatedListeners, setupOneOffListeners, initUsername } from './landing-events.js'; 16 + import type { EventDeps } from './landing-events.js'; 17 + import { createDocument, createFromTemplate, openDailyNote } from './landing-create.js'; 18 + import type { CreateDeps } from './landing-create.js'; 19 + import { setupDragAndDrop } from './landing-import.js'; 39 20 40 21 // --- DOM refs --- 41 22 const docListEl = document.getElementById('doc-list') as HTMLElement; ··· 72 53 const folderModalTitle = document.getElementById('folder-modal-title') as HTMLElement; 73 54 const folderNameInput = document.getElementById('folder-name-input') as HTMLInputElement; 74 55 const folderCancel = document.getElementById('folder-cancel') as HTMLElement; 75 - const folderConfirm = document.getElementById('folder-confirm') as HTMLElement; 56 + const folderConfirmBtn = document.getElementById('folder-confirm') as HTMLElement; 76 57 const moveModal = document.getElementById('move-modal') as HTMLElement; 77 58 const moveFolderList = document.getElementById('move-folder-list') as HTMLElement; 78 59 const moveCancel = document.getElementById('move-cancel') as HTMLElement; 79 60 const userBadge = document.getElementById('user-badge') as HTMLElement; 80 61 81 62 // --- State --- 82 - let allDocs: DocumentMeta[] = []; // raw from server, with _decryptedName populated 83 - let trashedDocs: DocumentMeta[] = []; // trashed docs from server 63 + let allDocs: DocumentMeta[] = []; 64 + let trashedDocs: DocumentMeta[] = []; 84 65 let currentSort: string = localStorage.getItem('tools-sort') || DEFAULT_SORT; 85 66 let stars: StarMap = JSON.parse(localStorage.getItem('tools-stars') || '{}'); 86 67 let folders: Folder[] = JSON.parse(localStorage.getItem('tools-folders') || '[]'); 87 68 let folderAssignments: FolderAssignments = JSON.parse(localStorage.getItem('tools-folder-assignments') || '{}'); 88 - let currentFolderId: string | null = null; // null = root / All Documents 69 + let currentFolderId: string | null = null; 89 70 let recentIds: string[] = JSON.parse(localStorage.getItem('tools-recent') || '[]'); 90 71 let searchQuery = ''; 91 72 let activeTagFilter: string | null = null; 92 73 let trashExpanded = false; 93 74 let viewMode: 'list' | 'grid' = (localStorage.getItem('tools-view-mode') as 'list' | 'grid') || 'list'; 94 - 95 - // Folder modal state 96 - let folderModalMode: 'create' | 'rename' = 'create'; 97 - let folderModalTargetId: string | null = null; 98 - 99 - // Move modal state 100 75 let moveDocId: string | null = null; 101 76 102 - // --- Event delegation for render-cycle elements --- 103 - // These listeners are attached ONCE and use event delegation to handle 104 - // dynamically-rendered elements, preventing listener accumulation. 105 - function setupDelegatedListeners() { 106 - // --- docListEl: star, delete, move, duplicate, tag-edit, recent tracking --- 107 - docListEl.addEventListener('click', async (e) => { 108 - const target = e.target as HTMLElement; 109 - 110 - // Star toggle 111 - const starBtn = target.closest('.doc-star') as HTMLElement | null; 112 - if (starBtn) { 113 - e.preventDefault(); 114 - e.stopPropagation(); 115 - stars = toggleStar(stars, starBtn.dataset.id); 116 - localStorage.setItem('tools-stars', JSON.stringify(stars)); 117 - renderDocuments(); 118 - return; 119 - } 120 - 121 - // Delete (trash) 122 - const deleteBtn = target.closest('.doc-item-delete') as HTMLElement | null; 123 - if (deleteBtn) { 124 - e.preventDefault(); 125 - e.stopPropagation(); 126 - const id = deleteBtn.dataset.id; 127 - const trashRes = await fetch(`/api/documents/${id}/trash`, { method: 'PUT' }); 128 - if (!trashRes.ok) { showToast('Failed to trash document', 4000, true); return; } 129 - const doc = allDocs.find(d => d.id === id); 130 - if (doc) { 131 - doc.deleted_at = new Date().toISOString(); 132 - allDocs = allDocs.filter(d => d.id !== id); 133 - trashedDocs = [doc, ...trashedDocs]; 134 - } 135 - renderDocuments(); 136 - showToast('Document moved to trash', 5000, false, async () => { 137 - await fetch(`/api/documents/${id}/restore`, { method: 'PUT' }); 138 - if (doc) { 139 - doc.deleted_at = null; 140 - trashedDocs = trashedDocs.filter(d => d.id !== id); 141 - allDocs = [...allDocs, doc]; 142 - } 143 - renderDocuments(); 144 - }); 145 - return; 146 - } 147 - 148 - // Move to folder 149 - const moveBtn = target.closest('.doc-item-move') as HTMLElement | null; 150 - if (moveBtn) { 151 - e.preventDefault(); 152 - e.stopPropagation(); 153 - showMoveModal(moveBtn.dataset.id); 154 - return; 155 - } 156 - 157 - // Duplicate 158 - const dupBtn = target.closest('.doc-item-duplicate') as HTMLElement | null; 159 - if (dupBtn) { 160 - e.preventDefault(); 161 - e.stopPropagation(); 162 - const id = dupBtn.dataset.id; 163 - const originalDoc = allDocs.find(d => d.id === id); 164 - if (!originalDoc) return; 165 - try { 166 - const res = await fetch('/api/documents', { 167 - method: 'POST', 168 - headers: { 'Content-Type': 'application/json' }, 169 - body: JSON.stringify({ type: originalDoc.type, name_encrypted: originalDoc.name_encrypted }), 170 - }); 171 - if (!res.ok) throw new Error('Create failed'); 172 - const { id: newId } = await res.json(); 173 - const snapRes = await fetch(`/api/documents/${id}/snapshot`); 174 - if (snapRes.ok) { 175 - const blob = await snapRes.blob(); 176 - await fetch(`/api/documents/${newId}/snapshot`, { method: 'PUT', body: blob }); 177 - } 178 - const keys = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 179 - if (keys[id]) { 180 - keys[newId] = keys[id]; 181 - localStorage.setItem('tools-keys', JSON.stringify(keys)); 182 - } 183 - if (currentFolderId) { 184 - folderAssignments = moveToFolder(folderAssignments, newId, currentFolderId); 185 - localStorage.setItem('tools-folder-assignments', JSON.stringify(folderAssignments)); 186 - } 187 - await loadDocuments(); 188 - showToast('Document duplicated'); 189 - } catch { 190 - showToast('Failed to duplicate document', 4000, true); 191 - } 192 - return; 193 - } 194 - 195 - // Tag edit 196 - const tagBtn = target.closest('.doc-item-tag-edit') as HTMLElement | null; 197 - if (tagBtn) { 198 - e.preventDefault(); 199 - e.stopPropagation(); 200 - const id = tagBtn.dataset.id; 201 - const doc = allDocs.find(d => d.id === id); 202 - if (!doc) return; 203 - const current = parseTags(doc.tags); 204 - const input = prompt('Tags (comma-separated):', current.join(', ')); 205 - if (input === null) return; 206 - const newTags = input.split(',').map(t => t.trim()).filter(Boolean); 207 - doc.tags = JSON.stringify(newTags); 208 - if (id) saveDocumentTags(id, newTags); 209 - renderDocuments(); 210 - return; 211 - } 212 - 213 - // Track recent docs on click (links that navigate away) 214 - const docLink = target.closest('a[data-doc-id]') as HTMLElement | null; 215 - if (docLink) { 216 - const docId = docLink.dataset.docId; 217 - if (docId) { 218 - recentIds = trackRecentDoc(recentIds, docId); 219 - localStorage.setItem('tools-recent', JSON.stringify(recentIds)); 220 - } 221 - } 222 - }); 223 - 224 - // --- folderListEl: navigate, rename, delete --- 225 - folderListEl.addEventListener('click', (e) => { 226 - const target = e.target as HTMLElement; 227 - 228 - // Rename folder 229 - const renameBtn = target.closest('.folder-rename') as HTMLElement | null; 230 - if (renameBtn) { 231 - e.stopPropagation(); 232 - const folder = folders.find(f => f.id === renameBtn.dataset.id); 233 - if (!folder) return; 234 - folderModalMode = 'rename'; 235 - folderModalTargetId = folder.id; 236 - folderModalTitle.textContent = 'Rename Folder'; 237 - folderNameInput.value = folder.name; 238 - folderConfirm.textContent = 'Rename'; 239 - folderModal.style.display = ''; 240 - folderNameInput.focus(); 241 - folderNameInput.select(); 242 - return; 243 - } 244 - 245 - // Delete folder 246 - const deleteBtn = target.closest('.folder-delete') as HTMLElement | null; 247 - if (deleteBtn) { 248 - e.stopPropagation(); 249 - const folder = folders.find(f => f.id === deleteBtn.dataset.id); 250 - if (!folder) return; 251 - if (!confirm(`Delete folder "${folder.name}"? Documents inside will be moved to the root.`)) return; 252 - folderAssignments = clearFolderAssignments(folderAssignments, folder.id); 253 - folders = deleteFolder(folders, folder.id); 254 - localStorage.setItem('tools-folders', JSON.stringify(folders)); 255 - localStorage.setItem('tools-folder-assignments', JSON.stringify(folderAssignments)); 256 - renderDocuments(); 257 - return; 258 - } 259 - 260 - // Navigate into folder (but not if clicking actions) 261 - const card = target.closest('.folder-card') as HTMLElement | null; 262 - if (card && !target.closest('.folder-card-actions')) { 263 - currentFolderId = card.dataset.folderId; 264 - renderDocuments(); 265 - } 266 - }); 267 - 268 - // --- trashListEl: restore, permanent delete, empty all --- 269 - trashListEl.addEventListener('click', async (e) => { 270 - const target = e.target as HTMLElement; 271 - 272 - // Restore 273 - const restoreBtn = target.closest('.trash-restore') as HTMLElement | null; 274 - if (restoreBtn) { 275 - const id = restoreBtn.dataset.id; 276 - await fetch(`/api/documents/${id}/restore`, { method: 'PUT' }); 277 - const doc = trashedDocs.find(d => d.id === id); 278 - if (doc) { 279 - doc.deleted_at = null; 280 - trashedDocs = trashedDocs.filter(d => d.id !== id); 281 - allDocs = [...allDocs, doc]; 282 - } 283 - renderDocuments(); 284 - return; 285 - } 286 - 287 - // Permanent delete 288 - const permBtn = target.closest('.trash-permanent') as HTMLElement | null; 289 - if (permBtn) { 290 - const doc = trashedDocs.find(d => d.id === permBtn.dataset.id); 291 - const name = doc?._decryptedName || 'this document'; 292 - if (!confirm(`Permanently delete "${name}"? This cannot be undone.`)) return; 293 - const id = permBtn.dataset.id; 294 - await fetch(`/api/documents/${id}`, { method: 'DELETE' }); 295 - const k = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 296 - delete k[id]; 297 - localStorage.setItem('tools-keys', JSON.stringify(k)); 298 - trashedDocs = trashedDocs.filter(d => d.id !== id); 299 - renderDocuments(); 300 - return; 301 - } 302 - 303 - // Empty all trash 304 - const emptyBtn = target.closest('.trash-empty-all') as HTMLElement | null; 305 - if (emptyBtn) { 306 - const docs = trashedDocs; 307 - if (!confirm(`Permanently delete all ${docs.length} trashed documents? This cannot be undone.`)) return; 308 - const k = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 309 - for (const doc of docs) { 310 - await fetch(`/api/documents/${doc.id}`, { method: 'DELETE' }).catch(() => showToast('Failed to delete document from server', 4000, true)); 311 - delete k[doc.id]; 312 - } 313 - localStorage.setItem('tools-keys', JSON.stringify(k)); 314 - trashedDocs = []; 315 - renderDocuments(); 316 - } 317 - }); 318 - 319 - // --- breadcrumbsEl: navigate --- 320 - breadcrumbsEl.addEventListener('click', (e) => { 321 - const btn = (e.target as HTMLElement).closest('.breadcrumb-link') as HTMLElement | null; 322 - if (btn) { 323 - currentFolderId = btn.dataset.folderId || null; 324 - renderDocuments(); 325 - } 326 - }); 327 - 328 - // --- moveFolderList: select folder --- 329 - moveFolderList.addEventListener('click', (e) => { 330 - const btn = (e.target as HTMLElement).closest('.move-option') as HTMLElement | null; 331 - if (btn) { 332 - const fid = btn.dataset.folderId || null; 333 - folderAssignments = moveToFolder(folderAssignments, moveDocId, fid); 334 - localStorage.setItem('tools-folder-assignments', JSON.stringify(folderAssignments)); 335 - moveModal.style.display = 'none'; 336 - moveDocId = null; 337 - renderDocuments(); 338 - } 339 - }); 340 - 341 - // --- Tag filter bar (delegated on parent since tagBarEl may not exist yet) --- 342 - const tagParent = docListEl.parentElement; 343 - if (tagParent) { 344 - tagParent.addEventListener('click', (e) => { 345 - const btn = (e.target as HTMLElement).closest('.tag-filter-pill') as HTMLElement | null; 346 - if (btn) { 347 - const tag = btn.dataset.tag || null; 348 - activeTagFilter = tag || null; 349 - renderDocuments(); 350 - } 351 - }); 352 - } 353 - 354 - // --- Recent section (delegated on parent) --- 355 - const recentEl = document.getElementById('recent-section'); 356 - if (recentEl) { 357 - recentEl.addEventListener('click', (e) => { 358 - const link = (e.target as HTMLElement).closest('a.recent-card[data-doc-id]') as HTMLElement | null; 359 - if (link) { 360 - const docId = link.dataset.docId; 361 - if (docId) { 362 - recentIds = trackRecentDoc(recentIds, docId); 363 - localStorage.setItem('tools-recent', JSON.stringify(recentIds)); 364 - } 365 - } 366 - }); 367 - } 368 - } 369 - 370 - setupDelegatedListeners(); 371 - 372 77 // --- Migrate legacy localStorage keys --- 373 78 if (localStorage.getItem('crypt-keys') && !localStorage.getItem('tools-keys')) { 374 79 localStorage.setItem('tools-keys', localStorage.getItem('crypt-keys')!); ··· 379 84 localStorage.removeItem('crypt-username'); 380 85 } 381 86 382 - // --- Sort labels --- 383 - const SORT_LABELS: SortLabels = { 384 - updated: 'Last updated', 385 - created: 'Created', 386 - name: 'Name', 387 - type: 'Type', 388 - }; 389 - 390 - // --- Tailscale identity + username prompt --- 391 - interface TsIdentity { login: string; name: string; profilePic: string | null; } 392 - let tsIdentity: TsIdentity | null = null; 393 - 394 - async function initUsername() { 395 - // Try Tailscale identity first (injected by Tailscale Serve) 396 - try { 397 - const res = await fetch('/api/me'); 398 - const data = await res.json(); 399 - if (data.login) { 400 - tsIdentity = data as TsIdentity; 401 - localStorage.setItem('tools-username', tsIdentity.name); 402 - showUserBadge(tsIdentity.name, tsIdentity.profilePic); 403 - return; 404 - } 405 - } catch { /* anonymous/local access — fall through */ } 406 - 407 - // Fall back to localStorage username 408 - const existing = localStorage.getItem('tools-username'); 409 - if (existing) { 410 - showUserBadge(existing, null); 411 - return; 412 - } 413 - usernameModal.style.display = ''; 414 - usernameInput.focus(); 415 - } 416 - 417 - function showUserBadge(name: string, profilePic?: string | null): void { 418 - if (profilePic) { 419 - userBadge.innerHTML = `<img src="${profilePic}" alt="" class="user-avatar" />${name}`; 420 - } else { 421 - userBadge.textContent = name; 422 - } 423 - userBadge.title = tsIdentity ? `${tsIdentity.name} (${tsIdentity.login})` : 'Click to change name'; 424 - userBadge.style.display = ''; 425 - } 426 - 427 - function saveUsername(name: string): void { 428 - localStorage.setItem('tools-username', name); 429 - usernameModal.style.display = 'none'; 430 - showUserBadge(name, null); 431 - } 432 - 433 - usernameConfirm.addEventListener('click', () => { 434 - const val = usernameInput.value.trim(); 435 - const result = validateUsername(val); 436 - if (result.valid) { 437 - saveUsername(val); 438 - } else { 439 - usernameInput.setCustomValidity(result.error); 440 - usernameInput.reportValidity(); 441 - } 442 - }); 443 - 444 - usernameSkip.addEventListener('click', () => { 445 - saveUsername(generateRandomUsername()); 446 - }); 447 - 448 - usernameInput.addEventListener('keydown', (e) => { 449 - if (e.key === 'Enter') usernameConfirm.click(); 450 - }); 451 - 452 - userBadge.addEventListener('click', () => { 453 - // If authenticated via Tailscale, show identity info instead of edit prompt 454 - if (tsIdentity) { 455 - alert(`Signed in as ${tsIdentity.name}\n${tsIdentity.login}`); 456 - return; 457 - } 458 - const current = localStorage.getItem('tools-username') || ''; 459 - const newName = prompt('Change your display name:', current); 460 - if (newName !== null) { 461 - const trimmed = newName.trim(); 462 - const result = validateUsername(trimmed); 463 - if (result.valid) { 464 - localStorage.setItem('tools-username', trimmed); 465 - showUserBadge(trimmed, null); 466 - } 467 - } 468 - }); 469 - 470 - // --- Create document --- 471 - async function createDocument(type: 'doc' | 'sheet' | 'form' | 'slide' | 'diagram'): Promise<void> { 472 - const key = await generateKey(); 473 - const keyStr = await exportKey(key); 474 - 475 - const nameMap = { doc: 'Untitled Document', sheet: 'Untitled Spreadsheet', form: 'Untitled Form', slide: 'Untitled Presentation', diagram: 'Untitled Diagram' }; 476 - const defaultName = nameMap[type]; 477 - const nameBytes = new TextEncoder().encode(defaultName); 478 - const { encrypt } = await import('./lib/crypto.js'); 479 - const encryptedName = await encrypt(nameBytes, key); 480 - const nameB64 = btoa(String.fromCharCode(...encryptedName)); 481 - 482 - const res = await fetch('/api/documents', { 483 - method: 'POST', 484 - headers: { 'Content-Type': 'application/json' }, 485 - body: JSON.stringify({ type, name_encrypted: nameB64 }), 486 - }); 487 - if (!res.ok) { showToast('Failed to create document', 4000, true); return; } 488 - const { id } = await res.json(); 489 - 490 - storeKey(id, keyStr); 491 - pushKeysToServer({ [id]: keyStr }); 492 - 493 - // If we're inside a folder, assign the new doc to it 494 - if (currentFolderId) { 495 - folderAssignments = moveToFolder(folderAssignments, id, currentFolderId); 496 - localStorage.setItem('tools-folder-assignments', JSON.stringify(folderAssignments)); 497 - } 498 - 499 - recentIds = trackRecentDoc(recentIds, id); 500 - localStorage.setItem('tools-recent', JSON.stringify(recentIds)); 501 - 502 - window.location.href = `${docPath(type)}/${id}#${keyStr}`; 503 - } 504 - 505 - async function createFromTemplate(templateId: string): Promise<void> { 506 - const template = getTemplate(templateId); 507 - if (!template) return; 508 - 509 - const key = await generateKey(); 510 - const keyStr = await exportKey(key); 511 - 512 - const nameBytes = new TextEncoder().encode(template.name); 513 - const { encrypt } = await import('./lib/crypto.js'); 514 - const encryptedName = await encrypt(nameBytes, key); 515 - const nameB64 = btoa(String.fromCharCode(...encryptedName)); 516 - 517 - const res = await fetch('/api/documents', { 518 - method: 'POST', 519 - headers: { 'Content-Type': 'application/json' }, 520 - body: JSON.stringify({ type: template.type, name_encrypted: nameB64 }), 521 - }); 522 - if (!res.ok) { showToast('Failed to create document', 4000, true); return; } 523 - const { id } = await res.json(); 524 - 525 - storeKey(id, keyStr); 526 - pushKeysToServer({ [id]: keyStr }); 527 - 528 - if (currentFolderId) { 529 - folderAssignments = moveToFolder(folderAssignments, id, currentFolderId); 530 - localStorage.setItem('tools-folder-assignments', JSON.stringify(folderAssignments)); 531 - } 532 - 533 - // Store template content for the editor to pick up 534 - sessionStorage.setItem(`template-content-${id}`, template.content); 535 - sessionStorage.setItem(`template-type-${id}`, template.type); 536 - 537 - recentIds = trackRecentDoc(recentIds, id); 538 - localStorage.setItem('tools-recent', JSON.stringify(recentIds)); 87 + // --- Build shared deps objects --- 539 88 540 - const path = template.type === 'doc' ? '/docs' : '/sheets'; 541 - window.location.href = `${path}/${id}#${keyStr}`; 542 - } 89 + const renderDeps: RenderDeps = { 90 + docListEl, folderListEl, noResultsEl, breadcrumbsEl, 91 + trashSection, trashCount, trashListEl, 92 + viewToggleBtn, moveFolderList, moveModal, 543 93 544 - newDocBtn.addEventListener('click', (e) => { e.preventDefault(); createDocument('doc'); }); 545 - newSheetBtn.addEventListener('click', (e) => { e.preventDefault(); createDocument('sheet'); }); 546 - newFormBtn.addEventListener('click', (e) => { e.preventDefault(); createDocument('form'); }); 547 - newSlideBtn.addEventListener('click', (e) => { e.preventDefault(); createDocument('slide'); }); 548 - newDiagramBtn.addEventListener('click', (e) => { e.preventDefault(); createDocument('diagram'); }); 94 + getAllDocs: () => allDocs, 95 + getTrashedDocs: () => trashedDocs, 96 + getStars: () => stars, 97 + getFolders: () => folders, 98 + getFolderAssignments: () => folderAssignments, 99 + getCurrentFolderId: () => currentFolderId, 100 + getSearchQuery: () => searchQuery, 101 + getActiveTagFilter: () => activeTagFilter, 102 + getCurrentSort: () => currentSort, 103 + getViewMode: () => viewMode, 104 + getTrashExpanded: () => trashExpanded, 105 + getRecentIds: () => recentIds, 106 + setMoveDocId: (id) => { moveDocId = id; }, 107 + }; 549 108 550 - // --- Daily Note --- 551 - async function openDailyNote(): Promise<void> { 552 - // Check if today's note already exists 553 - const existingId = findDailyNote(allDocs); 554 - if (existingId) { 555 - const keys = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 556 - const keyStr = keys[existingId]; 557 - if (keyStr) { 558 - recentIds = trackRecentDoc(recentIds, existingId); 559 - localStorage.setItem('tools-recent', JSON.stringify(recentIds)); 560 - window.location.href = `/docs/${existingId}#${keyStr}`; 561 - return; 562 - } 563 - } 109 + function renderDocuments() { doRender(renderDeps); } 564 110 565 - // Create a new daily note 566 - const key = await generateKey(); 567 - const keyStr = await exportKey(key); 568 - const name = formatDailyNoteName(); 569 - const nameBytes = new TextEncoder().encode(name); 570 - const { encrypt } = await import('./lib/crypto.js'); 571 - const encryptedName = await encrypt(nameBytes, key); 572 - const nameB64 = btoa(String.fromCharCode(...encryptedName)); 573 - 574 - const res = await fetch('/api/documents', { 575 - method: 'POST', 576 - headers: { 'Content-Type': 'application/json' }, 577 - body: JSON.stringify({ type: 'doc', name_encrypted: nameB64 }), 578 - }); 579 - if (!res.ok) { showToast('Failed to create document', 4000, true); return; } 580 - const { id } = await res.json(); 581 - 582 - storeKey(id, keyStr); 583 - pushKeysToServer({ [id]: keyStr }); 584 - 585 - // Store template for the editor to pick up 586 - const template = getDailyNoteTemplate(); 587 - sessionStorage.setItem('daily-note-template-' + id, template); 588 - 589 - recentIds = trackRecentDoc(recentIds, id); 590 - localStorage.setItem('tools-recent', JSON.stringify(recentIds)); 591 - 592 - window.location.href = `/docs/${id}#${keyStr}`; 593 - } 594 - 595 - dailyNoteBtn.addEventListener('click', (e) => { e.preventDefault(); openDailyNote(); }); 596 - 597 - // --- Backup Export/Import --- 598 - backupExportBtn.addEventListener('click', async () => { 599 - const docsToExport = allDocs.filter(d => !d.deleted_at && d._keyStr); 600 - if (docsToExport.length === 0) { 601 - showToast('No documents to export', 3000, true); 602 - return; 603 - } 604 - showToast(`Exporting ${docsToExport.length} document(s)...`); 605 - await exportBackup(docsToExport); 606 - showToast(`Backup exported (${docsToExport.length} documents)`); 607 - }); 608 - 609 - backupImportBtn.addEventListener('click', () => { backupImportInput.click(); }); 610 - backupImportInput.addEventListener('change', async () => { 611 - const file = backupImportInput.files?.[0]; 612 - if (!file) return; 613 - backupImportInput.value = ''; 614 - 615 - const json = await file.text(); 616 - const keys = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 617 - const result = await importBackup(json, keys); 618 - 619 - if (result.imported === 0 && result.skipped === 0) { 620 - showToast('Invalid backup file', 4000, true); 621 - } else { 622 - showToast(`Restored ${result.imported} document(s)` + (result.skipped ? `, ${result.skipped} skipped` : '')); 623 - loadDocuments(); 624 - } 625 - }); 626 - 627 - // --- Sort --- 628 - sortLabel.textContent = SORT_LABELS[currentSort] || SORT_LABELS.updated; 629 - 630 - sortBtn.addEventListener('click', (e) => { 631 - e.stopPropagation(); 632 - sortMenu.classList.toggle('open'); 633 - }); 634 - 635 - sortMenu.addEventListener('click', (e) => { 636 - const btn = e.target.closest('.sort-option'); 637 - if (!btn) return; 638 - currentSort = btn.dataset.sort; 639 - localStorage.setItem('tools-sort', currentSort); 640 - sortLabel.textContent = SORT_LABELS[currentSort]; 641 - sortMenu.classList.remove('open'); 642 - renderDocuments(); 643 - }); 644 - 645 - document.addEventListener('click', () => { 646 - sortMenu.classList.remove('open'); 647 - }); 648 - 649 - // --- Search --- 650 - searchInput.addEventListener('input', () => { 651 - searchQuery = searchInput.value; 652 - searchClear.style.display = searchQuery ? '' : 'none'; 653 - renderDocuments(); 654 - }); 655 - 656 - searchClear.addEventListener('click', () => { 657 - searchInput.value = ''; 658 - searchQuery = ''; 659 - searchClear.style.display = 'none'; 660 - renderDocuments(); 661 - }); 662 - 663 - // Init clear button visibility 664 - searchClear.style.display = 'none'; 665 - 666 - // --- Folders --- 667 - newFolderBtn.addEventListener('click', () => { 668 - folderModalMode = 'create'; 669 - folderModalTargetId = null; 670 - folderModalTitle.textContent = 'New Folder'; 671 - folderNameInput.value = ''; 672 - folderConfirm.textContent = 'Create'; 673 - folderModal.style.display = ''; 674 - folderNameInput.focus(); 675 - }); 111 + const eventDeps: EventDeps = { 112 + // DOM elements 113 + docListEl, folderListEl, trashListEl, breadcrumbsEl, 114 + moveFolderList, moveModal, 115 + searchInput, searchClear, 116 + sortBtn, sortLabel, sortMenu, 117 + newFolderBtn, viewToggleBtn, trashToggle, 118 + usernameModal, usernameInput, usernameSkip, usernameConfirm, 119 + folderModal, folderModalTitle, folderNameInput, 120 + folderCancel, folderConfirm: folderConfirmBtn, moveCancel, userBadge, 121 + backupExportBtn, backupImportBtn, backupImportInput, 676 122 677 - folderCancel.addEventListener('click', () => { 678 - folderModal.style.display = 'none'; 679 - }); 123 + // State accessors 124 + getAllDocs: () => allDocs, 125 + setAllDocs: (d) => { allDocs = d; }, 126 + getTrashedDocs: () => trashedDocs, 127 + setTrashedDocs: (d) => { trashedDocs = d; }, 128 + getStars: () => stars, 129 + setStars: (s) => { stars = s; }, 130 + getFolders: () => folders, 131 + setFolders: (f) => { folders = f; }, 132 + getFolderAssignments: () => folderAssignments, 133 + setFolderAssignments: (a) => { folderAssignments = a; }, 134 + getCurrentFolderId: () => currentFolderId, 135 + setCurrentFolderId: (id) => { currentFolderId = id; }, 136 + getRecentIds: () => recentIds, 137 + setRecentIds: (ids) => { recentIds = ids; }, 138 + getSearchQuery: () => searchQuery, 139 + setSearchQuery: (q) => { searchQuery = q; }, 140 + getActiveTagFilter: () => activeTagFilter, 141 + setActiveTagFilter: (t) => { activeTagFilter = t; }, 142 + getTrashExpanded: () => trashExpanded, 143 + setTrashExpanded: (v) => { trashExpanded = v; }, 144 + getViewMode: () => viewMode, 145 + setViewMode: (m) => { viewMode = m; }, 146 + getCurrentSort: () => currentSort, 147 + setCurrentSort: (s) => { currentSort = s; }, 148 + getMoveDocId: () => moveDocId, 149 + setMoveDocId: (id) => { moveDocId = id; }, 680 150 681 - folderConfirm.addEventListener('click', () => { 682 - const name = folderNameInput.value.trim(); 683 - if (!name) return; 151 + // Callbacks 152 + renderDocuments, 153 + loadDocuments, 154 + getRenderDeps: () => renderDeps, 155 + }; 684 156 685 - if (folderModalMode === 'create') { 686 - folders = createFolder(folders, name); 687 - } else if (folderModalMode === 'rename') { 688 - folders = renameFolder(folders, folderModalTargetId, name); 689 - } 690 - localStorage.setItem('tools-folders', JSON.stringify(folders)); 691 - folderModal.style.display = 'none'; 692 - renderDocuments(); 693 - }); 157 + const createDeps: CreateDeps = { 158 + getCurrentFolderId: () => currentFolderId, 159 + getFolderAssignments: () => folderAssignments, 160 + setFolderAssignments: (a) => { folderAssignments = a; }, 161 + getRecentIds: () => recentIds, 162 + setRecentIds: (ids) => { recentIds = ids; }, 163 + getAllDocs: () => allDocs, 164 + }; 694 165 695 - folderNameInput.addEventListener('keydown', (e) => { 696 - if (e.key === 'Enter') folderConfirm.click(); 697 - if (e.key === 'Escape') folderCancel.click(); 698 - }); 166 + // --- Wire up event listeners --- 167 + setupDelegatedListeners(eventDeps); 168 + setupOneOffListeners(eventDeps); 699 169 700 - moveCancel.addEventListener('click', () => { 701 - moveModal.style.display = 'none'; 702 - moveDocId = null; 703 - }); 170 + // --- New document buttons --- 171 + newDocBtn.addEventListener('click', (e) => { e.preventDefault(); createDocument(createDeps, 'doc'); }); 172 + newSheetBtn.addEventListener('click', (e) => { e.preventDefault(); createDocument(createDeps, 'sheet'); }); 173 + newFormBtn.addEventListener('click', (e) => { e.preventDefault(); createDocument(createDeps, 'form'); }); 174 + newSlideBtn.addEventListener('click', (e) => { e.preventDefault(); createDocument(createDeps, 'slide'); }); 175 + newDiagramBtn.addEventListener('click', (e) => { e.preventDefault(); createDocument(createDeps, 'diagram'); }); 176 + dailyNoteBtn.addEventListener('click', (e) => { e.preventDefault(); openDailyNote(createDeps); }); 704 177 705 - // --- Trash toggle --- 706 - trashToggle.addEventListener('click', () => { 707 - trashExpanded = !trashExpanded; 708 - trashToggle.querySelector('.trash-toggle-icon').textContent = trashExpanded ? '\u25BC' : '\u25B6'; 709 - renderDocuments(); // Re-render so renderTrash generates the HTML when expanded 178 + // --- Drag-and-drop file import --- 179 + setupDragAndDrop({ 180 + getCurrentFolderId: () => currentFolderId, 181 + getFolderAssignments: () => folderAssignments, 182 + setFolderAssignments: (a) => { folderAssignments = a; }, 710 183 }); 711 184 712 185 // --- Migrate localStorage trash to server (one-time) --- ··· 726 199 ); 727 200 localStorage.removeItem('tools-trash'); 728 201 } catch { 729 - // Corrupted data — just remove it 730 202 localStorage.removeItem('tools-trash'); 731 203 } 732 204 } ··· 779 251 } 780 252 } 781 253 782 - function updateViewToggle() { 783 - if (!viewToggleBtn) return; 784 - const gridIcon = viewToggleBtn.querySelector('.view-icon-grid') as HTMLElement | null; 785 - const listIcon = viewToggleBtn.querySelector('.view-icon-list') as HTMLElement | null; 786 - if (gridIcon) gridIcon.style.display = viewMode === 'list' ? '' : 'none'; 787 - if (listIcon) listIcon.style.display = viewMode === 'grid' ? '' : 'none'; 788 - } 789 - 790 - if (viewToggleBtn) { 791 - viewToggleBtn.addEventListener('click', () => { 792 - viewMode = viewMode === 'list' ? 'grid' : 'list'; 793 - localStorage.setItem('tools-view-mode', viewMode); 794 - renderDocuments(); 795 - }); 796 - } 797 - 798 - function renderRecentSection(keys: Record<string, string>) { 799 - const recentEl = document.getElementById('recent-section'); 800 - if (!recentEl) return; 801 - 802 - const recent = getRecentDocs(recentIds, allDocs, keys); 803 - if (recent.length === 0) { 804 - recentEl.innerHTML = ''; 805 - return; 806 - } 807 - 808 - let html = '<h3 class="recent-heading">Recent</h3><div class="recent-list">'; 809 - for (const doc of recent) { 810 - const path = docPath(doc.type); 811 - const icon = docIcon(doc.type); 812 - const name = doc._decryptedName || 'Encrypted Document'; 813 - const href = doc._keyStr ? `${path}/${doc.id}#${doc._keyStr}` : '#'; 814 - html += `<a class="recent-card" href="${href}" data-doc-id="${doc.id}"> 815 - <span class="recent-card-icon">${icon}</span> 816 - <span class="recent-card-name">${escapeHtml(name)}</span> 817 - <span class="recent-card-type">${doc.type}</span> 818 - </a>`; 819 - } 820 - html += '</div>'; 821 - recentEl.innerHTML = html; 822 - 823 - } 824 - 825 - function renderPinnedSection(keys: Record<string, string>) { 826 - const pinnedEl = document.getElementById('pinned-section'); 827 - if (!pinnedEl) return; 828 - 829 - const starSet = starredIdsSet(stars); 830 - if (starSet.size === 0) { 831 - pinnedEl.innerHTML = ''; 832 - return; 833 - } 834 - 835 - const pinned = allDocs.filter(d => starSet.has(d.id)); 836 - if (pinned.length === 0) { 837 - pinnedEl.innerHTML = ''; 838 - return; 839 - } 840 - 841 - let html = '<h3 class="pinned-heading">Pinned</h3><div class="pinned-list">'; 842 - for (const doc of pinned) { 843 - const path = docPath(doc.type); 844 - const icon = docIcon(doc.type); 845 - const name = doc._decryptedName || 'Encrypted Document'; 846 - const href = doc._keyStr ? `${path}/${doc.id}#${doc._keyStr}` : '#'; 847 - html += `<a class="pinned-card" href="${href}" data-doc-id="${doc.id}"> 848 - <span class="pinned-card-icon">${icon}</span> 849 - <span class="pinned-card-name">${escapeHtml(name)}</span> 850 - <span class="pinned-card-type">${doc.type}</span> 851 - </a>`; 852 - } 853 - html += '</div>'; 854 - pinnedEl.innerHTML = html; 855 - } 856 - 857 - function renderTagFilter(docs: DocumentMeta[]) { 858 - let tagBarEl = document.getElementById('tag-filter-bar'); 859 - const allTags = collectAllTags(docs); 860 - if (allTags.length === 0) { 861 - if (tagBarEl) tagBarEl.innerHTML = ''; 862 - return; 863 - } 864 - if (!tagBarEl) { 865 - tagBarEl = document.createElement('div'); 866 - tagBarEl.id = 'tag-filter-bar'; 867 - tagBarEl.className = 'tag-filter-bar'; 868 - const parent = docListEl.parentElement; 869 - if (parent) parent.insertBefore(tagBarEl, docListEl); 870 - } 871 - let html = '<span class="tag-filter-label">Tags:</span>'; 872 - html += `<button class="tag-filter-pill${!activeTagFilter ? ' active' : ''}" data-tag="">All</button>`; 873 - for (const tag of allTags) { 874 - const isActive = activeTagFilter === tag; 875 - html += `<button class="tag-filter-pill${isActive ? ' active' : ''}" data-tag="${escapeHtml(tag)}">${escapeHtml(tag)}</button>`; 876 - } 877 - tagBarEl.innerHTML = html; 878 - 879 - } 880 - 881 - function renderDocuments() { 882 - const keys = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 883 - const starSet = starredIdsSet(stars); 884 - 885 - // allDocs already contains only active docs (server filters out trashed) 886 - const active = allDocs; 887 - 888 - // Apply folder filter 889 - let visibleDocs; 890 - if (currentFolderId === null) { 891 - // "All Documents" view — show all active docs (flat) 892 - visibleDocs = active; 893 - } else { 894 - visibleDocs = getDocsInFolder(active, folderAssignments, currentFolderId); 895 - } 896 - 897 - // Apply search filter 898 - visibleDocs = filterBySearch(visibleDocs, searchQuery); 899 - 900 - // Apply tag filter 901 - if (activeTagFilter) { 902 - visibleDocs = filterByTag(visibleDocs, activeTagFilter) as DocumentMeta[]; 903 - } 904 - 905 - // Render tag filter bar 906 - renderTagFilter(active); 907 - 908 - // Apply sort 909 - const sorted = sortDocuments(visibleDocs, currentSort, starSet); 910 - 911 - // Render breadcrumbs 912 - renderBreadcrumbs(); 913 - 914 - // Render pinned section 915 - renderPinnedSection(keys); 916 - 917 - // Update view toggle icon state 918 - updateViewToggle(); 919 - 920 - // Render folder cards (only at root, not inside a folder, and not when searching) 921 - renderFolders(active); 922 - 923 - // Render document list 924 - if (sorted.length === 0 && active.length === 0 && trashedDocs.length === 0) { 925 - docListEl.innerHTML = ` 926 - <div class="empty-state"> 927 - <strong>No documents yet</strong> 928 - Create your first encrypted document or spreadsheet above. 929 - </div>`; 930 - noResultsEl.style.display = 'none'; 931 - } else if (sorted.length === 0) { 932 - docListEl.innerHTML = ''; 933 - noResultsEl.style.display = searchQuery ? '' : 'none'; 934 - } else { 935 - noResultsEl.style.display = 'none'; 936 - const isGrid = viewMode === 'grid'; 937 - let html = `<div class="doc-list${isGrid ? ' grid-view' : ''}">`; 938 - for (const doc of sorted) { 939 - const path = docPath(doc.type); 940 - const icon = docIcon(doc.type); 941 - const keyStr = doc._keyStr; 942 - const name = doc._decryptedName || 'Encrypted Document'; 943 - const isStarred = starSet.has(doc.id); 944 - const href = keyStr ? `${path}/${doc.id}#${keyStr}` : '#'; 945 - const date = new Date(doc.updated_at + 'Z').toLocaleDateString(undefined, { 946 - month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', 947 - }); 948 - 949 - const docTags = parseTags(doc.tags); 950 - const tagsHtml = docTags.map(t => `<span class="doc-tag-pill">${escapeHtml(t)}</span>`).join(''); 951 - 952 - if (isGrid) { 953 - html += ` 954 - <a class="doc-grid-card" href="${href}" ${!keyStr ? 'title="Key not available — you need the original link to decrypt"' : ''} data-doc-id="${doc.id}"> 955 - <div class="doc-grid-card-header"> 956 - <span class="doc-item-icon">${icon}</span> 957 - <button class="btn-icon doc-star" data-id="${doc.id}" title="${isStarred ? 'Remove from favorites' : 'Add to favorites'}">${isStarred ? '\u2605' : '\u2606'}</button> 958 - </div> 959 - <span class="doc-grid-card-name">${escapeHtml(name)}</span> 960 - ${tagsHtml ? `<span class="doc-item-tags">${tagsHtml}</span>` : ''} 961 - <div class="doc-grid-card-footer"> 962 - <span class="doc-item-type">${doc.type}</span> 963 - <span class="doc-item-date">${date}</span> 964 - </div> 965 - <div class="doc-grid-card-actions"> 966 - <button class="btn-icon doc-item-tag-edit" data-id="${doc.id}" title="Edit tags">#</button> 967 - <button class="btn-icon doc-item-move" data-id="${doc.id}" title="Move to folder">&#9647;</button> 968 - <button class="btn-icon doc-item-duplicate" data-id="${doc.id}" title="Duplicate">&#10697;</button> 969 - <button class="btn-icon doc-item-delete" data-id="${doc.id}" title="Move to trash">&#10005;</button> 970 - </div> 971 - </a>`; 972 - } else { 973 - html += ` 974 - <a class="doc-item" href="${href}" ${!keyStr ? 'title="Key not available — you need the original link to decrypt"' : ''} data-doc-id="${doc.id}"> 975 - <button class="btn-icon doc-star" data-id="${doc.id}" title="${isStarred ? 'Remove from favorites' : 'Add to favorites'}">${isStarred ? '\u2605' : '\u2606'}</button> 976 - <span class="doc-item-icon">${icon}</span> 977 - <span class="doc-item-name">${escapeHtml(name)}</span> 978 - ${tagsHtml ? `<span class="doc-item-tags">${tagsHtml}</span>` : ''} 979 - ${(doc as any).owner_name ? `<span class="doc-item-owner">${escapeHtml((doc as any).owner_name)}</span>` : ''} 980 - <span class="doc-item-type">${doc.type}</span> 981 - <span class="doc-item-date">${date}</span> 982 - <button class="btn-icon doc-item-tag-edit" data-id="${doc.id}" title="Edit tags">#</button> 983 - <button class="btn-icon doc-item-move" data-id="${doc.id}" title="Move to folder">&#9647;</button> 984 - <button class="btn-icon doc-item-duplicate" data-id="${doc.id}" title="Duplicate">&#10697;</button> 985 - <button class="btn-icon doc-item-delete" data-id="${doc.id}" title="Move to trash">&#10005;</button> 986 - </a>`; 987 - } 988 - } 989 - html += '</div>'; 990 - docListEl.innerHTML = html; 991 - } 992 - 993 - // Render recent documents section 994 - renderRecentSection(keys); 995 - 996 - // Render trash section 997 - renderTrash(trashedDocs, keys); 998 - } 999 - 1000 - function renderBreadcrumbs() { 1001 - const crumbs = buildBreadcrumbs(folders, currentFolderId); 1002 - let html = ''; 1003 - crumbs.forEach((crumb, i) => { 1004 - if (i > 0) html += ' <span class="breadcrumb-sep">/</span> '; 1005 - if (i < crumbs.length - 1) { 1006 - html += `<button class="breadcrumb-link" data-folder-id="${crumb.id || ''}">${escapeHtml(crumb.name)}</button>`; 1007 - } else { 1008 - html += `<span class="breadcrumb-current">${escapeHtml(crumb.name)}</span>`; 1009 - } 1010 - }); 1011 - breadcrumbsEl.innerHTML = html; 1012 - } 1013 - 1014 - function renderFolders(activeDocs: DocumentMeta[]): void { 1015 - // Only show folders at root level and when not searching 1016 - if (currentFolderId !== null || searchQuery) { 1017 - folderListEl.innerHTML = ''; 1018 - return; 1019 - } 1020 - 1021 - if (folders.length === 0) { 1022 - folderListEl.innerHTML = ''; 1023 - return; 1024 - } 1025 - 1026 - let html = '<div class="folder-grid">'; 1027 - for (const folder of folders) { 1028 - const docCount = activeDocs.filter(d => folderAssignments[d.id] === folder.id).length; 1029 - html += ` 1030 - <div class="folder-card" data-folder-id="${folder.id}"> 1031 - <span class="folder-card-icon">&#9647;</span> 1032 - <span class="folder-card-name">${escapeHtml(folder.name)}</span> 1033 - <span class="folder-card-count">${docCount} doc${docCount !== 1 ? 's' : ''}</span> 1034 - <div class="folder-card-actions"> 1035 - <button class="btn-icon folder-rename" data-id="${folder.id}" title="Rename">&#9998;</button> 1036 - <button class="btn-icon folder-delete" data-id="${folder.id}" title="Delete folder">&#10005;</button> 1037 - </div> 1038 - </div>`; 1039 - } 1040 - html += '</div>'; 1041 - folderListEl.innerHTML = html; 1042 - } 1043 - 1044 - function renderTrash(docs: DocumentMeta[], keys: Record<string, string>): void { 1045 - if (docs.length === 0) { 1046 - trashSection.style.display = 'none'; 1047 - return; 1048 - } 1049 - 1050 - trashSection.style.display = ''; 1051 - trashCount.textContent = `(${docs.length})`; 1052 - 1053 - if (!trashExpanded) { 1054 - trashListEl.style.display = 'none'; 1055 - return; 1056 - } 1057 - 1058 - trashListEl.style.display = ''; 1059 - let html = '<div class="trash-actions"><button class="btn-danger btn-sm trash-empty-all">Empty Trash</button></div>'; 1060 - html += '<div class="doc-list">'; 1061 - for (const doc of docs) { 1062 - const name = doc._decryptedName || 'Encrypted Document'; 1063 - const icon = doc.type === 'doc' ? '&#9998;' : '&#9638;'; 1064 - const deletedDate = doc.deleted_at 1065 - ? new Date(doc.deleted_at + 'Z').toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) 1066 - : ''; 1067 - 1068 - html += ` 1069 - <div class="doc-item trash-item"> 1070 - <span class="doc-item-icon">${icon}</span> 1071 - <span class="doc-item-name trash-name">${escapeHtml(name)}</span> 1072 - <span class="doc-item-date">Deleted ${deletedDate}</span> 1073 - <button class="btn-secondary btn-sm trash-restore" data-id="${doc.id}">Restore</button> 1074 - <button class="btn-danger btn-sm trash-permanent" data-id="${doc.id}">Delete permanently</button> 1075 - </div>`; 1076 - } 1077 - html += '</div>'; 1078 - trashListEl.innerHTML = html; 1079 - } 1080 - 1081 - function showMoveModal(docId: string): void { 1082 - moveDocId = docId; 1083 - const currentFolder = folderAssignments[docId] || null; 1084 - 1085 - let html = ''; 1086 - // Option to move to root 1087 - html += `<button class="move-option ${currentFolder === null ? 'active' : ''}" data-folder-id=""> 1088 - &#8962; Root (no folder) 1089 - </button>`; 1090 - for (const folder of folders) { 1091 - const active = currentFolder === folder.id ? 'active' : ''; 1092 - html += `<button class="move-option ${active}" data-folder-id="${folder.id}"> 1093 - &#9647; ${escapeHtml(folder.name)} 1094 - </button>`; 1095 - } 1096 - 1097 - if (folders.length === 0) { 1098 - html += '<p class="move-empty">No folders yet. Create one first.</p>'; 1099 - } 1100 - 1101 - moveFolderList.innerHTML = html; 1102 - moveModal.style.display = ''; 1103 - } 1104 - 1105 - function escapeHtml(text: string): string { 1106 - const div = document.createElement('div'); 1107 - div.textContent = text; 1108 - return div.innerHTML; 1109 - } 1110 - 1111 - // --- Close modals on backdrop click --- 1112 - [usernameModal, folderModal, moveModal].forEach(modal => { 1113 - modal.addEventListener('click', (e) => { 1114 - if (e.target === modal) { 1115 - modal.style.display = 'none'; 1116 - } 1117 - }); 1118 - }); 1119 - 1120 - // --- Drag-and-drop file import --- 1121 - const dropOverlay = document.getElementById('drop-overlay'); 1122 - let dragCounter = 0; 1123 - 1124 - function showDropOverlay() { 1125 - dropOverlay.style.display = ''; 1126 - } 1127 - 1128 - function hideDropOverlay() { 1129 - dropOverlay.style.display = 'none'; 1130 - } 1131 - 1132 - function showToast(message: string, duration = 3000, isError = false, onUndo?: () => void): void { 1133 - const existing = document.querySelector('.toast-notification'); 1134 - if (existing) existing.remove(); 1135 - const toast = document.createElement('div'); 1136 - toast.className = 'toast-notification' + (isError ? ' toast-error' : ''); 1137 - if (onUndo) { 1138 - toast.classList.add('toast-interactive'); 1139 - const msgSpan = document.createElement('span'); 1140 - msgSpan.textContent = message; 1141 - toast.appendChild(msgSpan); 1142 - const undoBtn = document.createElement('span'); 1143 - undoBtn.className = 'toast-undo'; 1144 - undoBtn.textContent = 'Undo'; 1145 - undoBtn.setAttribute('role', 'button'); 1146 - undoBtn.setAttribute('tabindex', '0'); 1147 - undoBtn.addEventListener('click', () => { 1148 - onUndo(); 1149 - toast.classList.remove('toast-visible'); 1150 - setTimeout(() => toast.remove(), 300); 1151 - }); 1152 - undoBtn.addEventListener('keydown', (e) => { 1153 - if (e.key === 'Enter' || e.key === ' ') { 1154 - e.preventDefault(); 1155 - undoBtn.click(); 1156 - } 1157 - }); 1158 - toast.appendChild(undoBtn); 1159 - } else { 1160 - toast.textContent = message; 1161 - } 1162 - document.body.appendChild(toast); 1163 - toast.offsetHeight; // force reflow 1164 - toast.classList.add('toast-visible'); 1165 - setTimeout(() => { 1166 - toast.classList.remove('toast-visible'); 1167 - setTimeout(() => toast.remove(), 300); 1168 - }, duration); 1169 - } 1170 - 1171 - document.addEventListener('dragenter', (e) => { 1172 - e.preventDefault(); 1173 - dragCounter++; 1174 - if (dragCounter === 1) showDropOverlay(); 1175 - }); 1176 - 1177 - document.addEventListener('dragover', (e) => { 1178 - e.preventDefault(); 1179 - e.dataTransfer.dropEffect = 'copy'; 1180 - }); 1181 - 1182 - document.addEventListener('dragleave', (e) => { 1183 - e.preventDefault(); 1184 - dragCounter--; 1185 - if (dragCounter <= 0) { 1186 - dragCounter = 0; 1187 - hideDropOverlay(); 1188 - } 1189 - }); 1190 - 1191 - async function importFile(file: File) { 1192 - const docType = getFileType(file.name); 1193 - const importType = getImportType(file.name); 1194 - 1195 - if (!docType || !importType) { 1196 - showToast(`Unsupported file type: .${file.name.split('.').pop()}`, 4000, true); 1197 - return; 1198 - } 1199 - 1200 - try { 1201 - // Generate encryption key 1202 - const key = await generateKey(); 1203 - const keyStr = await exportKey(key); 1204 - 1205 - // Use filename (without extension) as the document name 1206 - const fileBaseName = file.name.replace(/\.[^.]+$/, ''); 1207 - const defaultName = fileBaseName || (docType === 'doc' ? 'Untitled Document' : 'Untitled Spreadsheet'); 1208 - const nameBytes = new TextEncoder().encode(defaultName); 1209 - const { encrypt } = await import('./lib/crypto.js'); 1210 - const encryptedName = await encrypt(nameBytes, key); 1211 - const nameB64 = btoa(String.fromCharCode(...encryptedName)); 1212 - 1213 - // Create document via API 1214 - const res = await fetch('/api/documents', { 1215 - method: 'POST', 1216 - headers: { 'Content-Type': 'application/json' }, 1217 - body: JSON.stringify({ type: docType, name_encrypted: nameB64 }), 1218 - }); 1219 - if (!res.ok) { showToast('Failed to create document', 4000, true); return; } 1220 - const { id } = await res.json(); 1221 - 1222 - // Store encryption key 1223 - const keys = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 1224 - keys[id] = keyStr; 1225 - localStorage.setItem('tools-keys', JSON.stringify(keys)); 1226 - 1227 - // If inside a folder, assign to it 1228 - if (currentFolderId) { 1229 - folderAssignments = moveToFolder(folderAssignments, id, currentFolderId); 1230 - localStorage.setItem('tools-folder-assignments', JSON.stringify(folderAssignments)); 1231 - } 1232 - 1233 - // Read file and store in sessionStorage for the editor to pick up 1234 - const reader = new FileReader(); 1235 - reader.onload = () => { 1236 - const payload = JSON.stringify({ 1237 - name: file.name, 1238 - type: importType, 1239 - data: reader.result, // data URL (base64-encoded) 1240 - }); 1241 - sessionStorage.setItem(pendingImportKey(id), payload); 1242 - 1243 - // Navigate to the editor 1244 - window.location.href = buildEditorUrl(docType, id, keyStr); 1245 - }; 1246 - reader.onerror = () => { 1247 - showToast('Failed to read file', 4000, true); 1248 - }; 1249 - reader.readAsDataURL(file); 1250 - } catch (err) { 1251 - showToast('Failed to create document for import', 4000, true); 1252 - } 1253 - } 1254 - 1255 - document.addEventListener('drop', async (e) => { 1256 - e.preventDefault(); 1257 - dragCounter = 0; 1258 - hideDropOverlay(); 1259 - 1260 - const file = e.dataTransfer?.files[0]; 1261 - if (!file) return; 1262 - importFile(file); 1263 - }); 1264 - 1265 - // --- File import button (mobile-friendly alternative to drag-drop) --- 1266 - const fileImportBtn = document.getElementById('file-import-btn'); 1267 - const fileImportInput = document.getElementById('file-import-input') as HTMLInputElement | null; 1268 - if (fileImportBtn && fileImportInput) { 1269 - fileImportBtn.addEventListener('click', () => fileImportInput.click()); 1270 - fileImportInput.addEventListener('change', () => { 1271 - const file = fileImportInput.files?.[0]; 1272 - if (file) importFile(file); 1273 - fileImportInput.value = ''; 1274 - }); 1275 - } 1276 - 1277 254 // --- Command Palette --- 1278 255 createCommandPalette({ 1279 256 actions: [ 1280 - { 1281 - id: 'new-doc', 1282 - label: 'New Document', 1283 - category: 'action', 1284 - icon: '\u270e', 1285 - action: () => createDocument('doc'), 1286 - }, 1287 - { 1288 - id: 'new-sheet', 1289 - label: 'New Spreadsheet', 1290 - category: 'action', 1291 - icon: '\u25a6', 1292 - action: () => createDocument('sheet'), 1293 - }, 1294 - { 1295 - id: 'new-form', 1296 - label: 'New Form', 1297 - category: 'action', 1298 - icon: '\u2637', 1299 - action: () => createDocument('form'), 1300 - }, 1301 - { 1302 - id: 'new-slide', 1303 - label: 'New Presentation', 1304 - category: 'action', 1305 - icon: '\u25eb', 1306 - action: () => createDocument('slide'), 1307 - }, 1308 - { 1309 - id: 'new-diagram', 1310 - label: 'New Diagram', 1311 - category: 'action', 1312 - icon: '\u25d3', 1313 - action: () => createDocument('diagram'), 1314 - }, 1315 - { 1316 - id: 'daily-note', 1317 - label: "Today's Note", 1318 - category: 'action', 1319 - icon: '\u2666', 1320 - action: () => openDailyNote(), 1321 - }, 1322 - { 1323 - id: 'backup-export', 1324 - label: 'Export Backup', 1325 - category: 'action', 1326 - icon: '\u2913', 1327 - action: () => backupExportBtn.click(), 1328 - }, 1329 - { 1330 - id: 'backup-import', 1331 - label: 'Restore Backup', 1332 - category: 'action', 1333 - icon: '\u2912', 1334 - action: () => backupImportBtn.click(), 1335 - }, 257 + { id: 'new-doc', label: 'New Document', category: 'action', icon: '\u270e', action: () => createDocument(createDeps, 'doc') }, 258 + { id: 'new-sheet', label: 'New Spreadsheet', category: 'action', icon: '\u25a6', action: () => createDocument(createDeps, 'sheet') }, 259 + { id: 'new-form', label: 'New Form', category: 'action', icon: '\u2637', action: () => createDocument(createDeps, 'form') }, 260 + { id: 'new-slide', label: 'New Presentation', category: 'action', icon: '\u25eb', action: () => createDocument(createDeps, 'slide') }, 261 + { id: 'new-diagram', label: 'New Diagram', category: 'action', icon: '\u25d3', action: () => createDocument(createDeps, 'diagram') }, 262 + { id: 'daily-note', label: "Today's Note", category: 'action', icon: '\u2666', action: () => openDailyNote(createDeps) }, 263 + { id: 'backup-export', label: 'Export Backup', category: 'action', icon: '\u2913', action: () => backupExportBtn.click() }, 264 + { id: 'backup-import', label: 'Restore Backup', category: 'action', icon: '\u2912', action: () => backupImportBtn.click() }, 1336 265 ...BUILT_IN_TEMPLATES.map(t => ({ 1337 266 id: `template-${t.id}`, 1338 267 label: `Template: ${t.name}`, 1339 268 category: 'action' as const, 1340 269 icon: t.icon, 1341 - action: () => createFromTemplate(t.id), 270 + action: () => createFromTemplate(createDeps, t.id), 1342 271 })), 1343 272 ], 1344 273 fetchDocuments: async (): Promise<PaletteAction[]> => { 1345 - const keys = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 1346 274 return allDocs 1347 275 .filter(doc => doc._keyStr) 1348 276 .map(doc => { ··· 1360 288 1361 289 // --- Desktop app download --- 1362 290 function initDesktopDownload(): void { 1363 - // Don't show inside the Electron app itself 1364 291 if ((window as unknown as { electronAPI?: unknown }).electronAPI) return; 1365 292 1366 293 const el = document.getElementById('desktop-download'); ··· 1379 306 } 1380 307 1381 308 // --- Init --- 1382 - initUsername(); 309 + initUsername(eventDeps); 1383 310 syncKeys().then(() => loadDocuments()); 1384 311 initDesktopDownload(); 1385 312 ··· 1387 314 const urlAction = new URLSearchParams(window.location.search).get('action'); 1388 315 if (urlAction) { 1389 316 const actionMap: Record<string, () => void> = { 1390 - 'new-doc': () => createDocument('doc'), 1391 - 'new-sheet': () => createDocument('sheet'), 1392 - 'new-form': () => createDocument('form'), 1393 - 'new-slide': () => createDocument('slide'), 1394 - 'new-diagram': () => createDocument('diagram'), 317 + 'new-doc': () => createDocument(createDeps, 'doc'), 318 + 'new-sheet': () => createDocument(createDeps, 'sheet'), 319 + 'new-form': () => createDocument(createDeps, 'form'), 320 + 'new-slide': () => createDocument(createDeps, 'slide'), 321 + 'new-diagram': () => createDocument(createDeps, 'diagram'), 1395 322 }; 1396 323 const handler = actionMap[urlAction]; 1397 324 if (handler) { 1398 - // Clear the action param from URL to prevent re-trigger on refresh 1399 325 history.replaceState(null, '', '/'); 1400 326 handler(); 1401 327 }