// ── Types ──────────────────────────────────────────────────── interface Paste { id: string; mimeType: string; description: string; tags: string[]; timestamp: number; fileName: string; fileData: string; // base64 (empty for text-only) } // Duck-typed DB interface — all three variants satisfy this interface PasteDB { selectFolder(): Promise; hasFolderAccess(): Promise; requestFolderAccess(): Promise; sync(): Promise; collection(opts: { name: string; indexes: { name: string; fields: string[] }[]; }): { put(doc: Paste): Promise; all(): Promise; delete(id: string): Promise; }; on(event: string, handler: (...args: unknown[]) => void): () => void; } // ── Helpers ────────────────────────────────────────────────── function genId(): string { return Date.now().toString(36) + Math.random().toString(36).slice(2, 8); } function extractTags(text: string): string[] { return text .split(/\s+/) .filter((w) => w.startsWith('#') && w.length > 1) .map((w) => w.slice(1).replace(/[^a-zA-Z0-9_-]+$/, '')) .filter((t) => t.length > 0); } function fileToBase64(file: Blob): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { const result = reader.result as string; resolve(result.split(',')[1] || ''); }; reader.onerror = reject; reader.readAsDataURL(file); }); } function escapeHtml(s: string): string { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } function fmtTime(ts: number): string { const d = new Date(ts); const p = (n: number) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`; } const pdfSvg = 'PDF'; const fileSvg = ''; // ── Main init ──────────────────────────────────────────────── export async function initPasteApp(db: PasteDB, variantLabel: string) { const pastes = db.collection({ name: 'pastes', indexes: [{ name: 'byTimestamp', fields: ['timestamp'] }], }); const $ = (s: string) => document.querySelector(s)!; const input = $('#paste-input') as HTMLInputElement; const statusEl = $('#status')!; const itemsEl = $('#items')!; const selectFolderBtn = $('#select-folder') as HTMLButtonElement; const folderStatusEl = $('#folder-status')!; const variantEl = $('#variant-label'); if (variantEl) variantEl.textContent = variantLabel; // ── Folder management ──────────────────────────────────── function updateFolderUI(connected: boolean) { if (connected) { folderStatusEl.textContent = 'Folder connected'; folderStatusEl.className = 'status ok'; selectFolderBtn.textContent = 'Change folder'; } else { folderStatusEl.textContent = 'No folder selected'; folderStatusEl.className = 'status'; selectFolderBtn.textContent = 'Select sync folder'; } } selectFolderBtn.addEventListener('click', async () => { try { await db.selectFolder(); updateFolderUI(true); await refreshItems(); } catch (e: unknown) { setStatus('Folder: ' + (e as Error).message, 'err'); } }); // Check if we already have access from a previous session const hasAccess = await db.hasFolderAccess(); if (hasAccess) { const granted = await db.requestFolderAccess(); updateFolderUI(granted); } else { updateFolderUI(false); } // ── Saving spinner ───────────────────────────────────────── const spinner = document.createElement('div'); spinner.className = 'saving-spinner hidden'; spinner.innerHTML = ' Syncing...'; document.body.appendChild(spinner); function showSaving(active: boolean) { spinner.classList.toggle('hidden', !active); } // ── Status ─────────────────────────────────────────────── function setStatus(msg: string, type?: string) { statusEl.textContent = msg; statusEl.className = 'status' + (type ? ' ' + type : ''); if (type === 'ok') setTimeout(() => { statusEl.textContent = ''; statusEl.className = 'status'; }, 2500); } // ── Save text ──────────────────────────────────────────── async function pasteText(text: string) { setStatus('Saving...'); try { const paste: Paste = { id: genId(), mimeType: 'text/plain', description: text, tags: extractTags(text), timestamp: Date.now(), fileName: '', fileData: '', }; await pastes.put(paste); setStatus('Saved', 'ok'); await refreshItems(); } catch (e: unknown) { setStatus('Error: ' + (e as Error).message, 'err'); } } // ── Save file ──────────────────────────────────────────── async function pasteFile( file: Blob, description: string, fileName: string, ) { setStatus('Reading file...'); try { const base64 = await fileToBase64(file); const paste: Paste = { id: genId(), mimeType: file.type || 'application/octet-stream', description, tags: extractTags(description), timestamp: Date.now(), fileName, fileData: base64, }; // Show spinner — IDB write is fast, but hashing + transport are slow showSaving(true); await pastes.put(paste); // UI refreshes immediately via 'change' event (IDB write triggers it) // Transports (folder, Nostr) continue in background showSaving(false); setStatus('Saved', 'ok'); } catch (e: unknown) { showSaving(false); setStatus('Error: ' + (e as Error).message, 'err'); } } // ── Refresh & render ───────────────────────────────────── async function refreshItems() { try { const all: Paste[] = await pastes.all(); all.sort((a, b) => b.timestamp - a.timestamp); renderItems(all); } catch (e: unknown) { setStatus('Load error: ' + (e as Error).message, 'err'); } } function renderItems(items: Paste[]) { if (!items || items.length === 0) { itemsEl.innerHTML = '
Nothing here yet. Type something or paste an image.
'; return; } itemsEl.innerHTML = items .map((it) => { const time = fmtTime(it.timestamp); const shortId = it.id.substring(0, 12); const isImage = it.mimeType.startsWith('image/'); const isPdf = it.mimeType === 'application/pdf'; const isText = it.mimeType.startsWith('text/'); let thumb = ''; if (isImage && it.fileData) { const src = `data:${it.mimeType};base64,${it.fileData}`; thumb = ``; } else if (isPdf) { thumb = `
${pdfSvg}
`; } else if (!isText && it.fileData) { thumb = `
${fileSvg}
`; } let content = ''; if (isImage && it.fileData) { const src = `data:${it.mimeType};base64,${it.fileData}`; content = `
pasted image
`; } if (it.description) { content += `
${escapeHtml(it.description)}
`; } let fileLink = ''; if (it.fileName && !isText) { fileLink = `
${escapeHtml(it.fileName)}
`; } let tagsHtml = ''; if (it.tags && it.tags.length > 0) { tagsHtml = '
' + it.tags .map((t) => `#${escapeHtml(t)}`) .join('') + '
'; } return ( `
` + thumb + `
` + `
` + `${time}` + `${shortId}` + `${escapeHtml(it.mimeType)}` + `
` + fileLink + content + tagsHtml + `
` ); }) .join(''); } // ── Event listeners ────────────────────────────────────── input.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === 'Enter' && input.value.trim()) { pasteText(input.value.trim()); input.value = ''; } }); document.addEventListener('paste', (e: ClipboardEvent) => { const items = e.clipboardData?.items; if (!items) return; for (const item of items) { if (item.type.startsWith('image/')) { e.preventDefault(); const blob = item.getAsFile(); if (blob) { const ext = blob.type === 'image/png' ? '.png' : blob.type === 'image/jpeg' ? '.jpg' : blob.type === 'image/gif' ? '.gif' : blob.type === 'image/webp' ? '.webp' : '.bin'; pasteFile(blob, input.value.trim(), 'clipboard' + ext); input.value = ''; } return; } } }); const fileInput = $('#file-input') as HTMLInputElement; ($('#clip-btn') as HTMLElement).addEventListener('click', () => fileInput.click(), ); fileInput.addEventListener('change', () => { const file = fileInput.files?.[0]; if (file) { pasteFile(file, input.value.trim(), file.name); input.value = ''; fileInput.value = ''; } }); // Live updates from sync db.on('change', () => refreshItems()); // Initial load await refreshItems(); }