Four variants of the same sync library (IndexedDB, NeDB, SQLite WASM, sql.js) plus a paste-bin demo app for testing multi-browser sync via shared folders. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
319 lines
10 KiB
TypeScript
319 lines
10 KiB
TypeScript
// ── 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<void>;
|
|
hasFolderAccess(): Promise<boolean>;
|
|
requestFolderAccess(): Promise<boolean>;
|
|
sync(): Promise<void>;
|
|
collection(opts: {
|
|
name: string;
|
|
indexes: { name: string; fields: string[] }[];
|
|
}): {
|
|
put(doc: Paste): Promise<void>;
|
|
all(): Promise<Paste[]>;
|
|
delete(id: string): Promise<void>;
|
|
};
|
|
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<string> {
|
|
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 =
|
|
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><text x="12" y="17" text-anchor="middle" fill="currentColor" stroke="none" font-size="6" font-weight="bold" font-family="sans-serif">PDF</text></svg>';
|
|
|
|
const fileSvg =
|
|
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>';
|
|
|
|
// ── 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);
|
|
}
|
|
|
|
// ── 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('Saving...');
|
|
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,
|
|
};
|
|
await pastes.put(paste);
|
|
setStatus('Saved', 'ok');
|
|
await refreshItems();
|
|
} catch (e: unknown) {
|
|
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 =
|
|
'<div class="empty">Nothing here yet. Type something or paste an image.</div>';
|
|
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 = `<img class="item-thumb" src="${src}" alt="">`;
|
|
} else if (isPdf) {
|
|
thumb = `<div class="item-icon pdf">${pdfSvg}</div>`;
|
|
} else if (!isText && it.fileData) {
|
|
thumb = `<div class="item-icon file">${fileSvg}</div>`;
|
|
}
|
|
|
|
let content = '';
|
|
if (isImage && it.fileData) {
|
|
const src = `data:${it.mimeType};base64,${it.fileData}`;
|
|
content = `<div class="item-image"><img src="${src}" alt="pasted image" loading="lazy"></div>`;
|
|
}
|
|
if (it.description) {
|
|
content += `<div class="item-content">${escapeHtml(it.description)}</div>`;
|
|
}
|
|
|
|
let fileLink = '';
|
|
if (it.fileName && !isText) {
|
|
fileLink = `<div class="item-filename">${escapeHtml(it.fileName)}</div>`;
|
|
}
|
|
|
|
let tagsHtml = '';
|
|
if (it.tags && it.tags.length > 0) {
|
|
tagsHtml =
|
|
'<div class="item-tags">' +
|
|
it.tags
|
|
.map((t) => `<span class="tag">#${escapeHtml(t)}</span>`)
|
|
.join('') +
|
|
'</div>';
|
|
}
|
|
|
|
return (
|
|
`<div class="item">` +
|
|
thumb +
|
|
`<div class="item-body">` +
|
|
`<div class="item-meta">` +
|
|
`<span class="time">${time}</span>` +
|
|
`<span class="hash">${shortId}</span>` +
|
|
`<span>${escapeHtml(it.mimeType)}</span>` +
|
|
`</div>` +
|
|
fileLink +
|
|
content +
|
|
tagsHtml +
|
|
`</div></div>`
|
|
);
|
|
})
|
|
.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();
|
|
}
|