/** * — file browsing, upload, share links, and memory cards. * * Attributes: * space="slug" — shared space to browse (default: "default") */ class FolkFileBrowser extends HTMLElement { private shadow: ShadowRoot; private space = "default"; private files: any[] = []; private cards: any[] = []; private tab: "files" | "cards" = "files"; private loading = false; constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); } connectedCallback() { this.space = this.getAttribute("space") || "default"; if (this.space === "demo") { this.loadDemoData(); return; } this.render(); this.loadFiles(); this.loadCards(); } private loadDemoData() { const now = Date.now(); this.files = [ { id: "demo-file-1", name: "meeting-notes-feb2026.md", original_filename: "meeting-notes-feb2026.md", title: "meeting-notes-feb2026.md", size: 12288, file_size: 12288, mime_type: "text/markdown", created_at: new Date(now - 3 * 86400000).toISOString(), updated_at: new Date(now - 1 * 86400000).toISOString(), space: "demo", }, { id: "demo-file-2", name: "budget-proposal.pdf", original_filename: "budget-proposal.pdf", title: "budget-proposal.pdf", size: 2202009, file_size: 2202009, mime_type: "application/pdf", created_at: new Date(now - 7 * 86400000).toISOString(), updated_at: new Date(now - 5 * 86400000).toISOString(), space: "demo", }, { id: "demo-file-3", name: "community-logo.svg", original_filename: "community-logo.svg", title: "community-logo.svg", size: 46080, file_size: 46080, mime_type: "image/svg+xml", created_at: new Date(now - 14 * 86400000).toISOString(), updated_at: new Date(now - 14 * 86400000).toISOString(), space: "demo", }, { id: "demo-file-4", name: "workshop-recording.mp4", original_filename: "workshop-recording.mp4", title: "workshop-recording.mp4", size: 157286400, file_size: 157286400, mime_type: "video/mp4", created_at: new Date(now - 2 * 86400000).toISOString(), updated_at: new Date(now - 2 * 86400000).toISOString(), space: "demo", }, { id: "demo-file-5", name: "member-directory.csv", original_filename: "member-directory.csv", title: "member-directory.csv", size: 8192, file_size: 8192, mime_type: "text/csv", created_at: new Date(now - 10 * 86400000).toISOString(), updated_at: new Date(now - 4 * 86400000).toISOString(), space: "demo", }, ]; this.cards = [ { id: "demo-card-1", title: "Design Sprint Outcomes", card_type: "summary", type: "summary", item_count: 3, body: "Key outcomes from the 5-day design sprint covering user flows, wireframes, and prototypes.", created_at: new Date(now - 5 * 86400000).toISOString(), space: "demo", }, { id: "demo-card-2", title: "Q1 Budget Allocation", card_type: "data", type: "data", item_count: 5, body: "Budget breakdown across infrastructure, development, community, marketing, and reserves.", created_at: new Date(now - 12 * 86400000).toISOString(), space: "demo", }, { id: "demo-card-3", title: "Community Principles", card_type: "reference", type: "reference", item_count: 7, body: "Seven guiding principles adopted by the community for governance and collaboration.", created_at: new Date(now - 20 * 86400000).toISOString(), space: "demo", }, ]; this.loading = false; this.render(); } private async loadFiles() { this.loading = true; this.render(); try { const base = this.getApiBase(); const res = await fetch(`${base}/api/files?space=${encodeURIComponent(this.space)}`); const data = await res.json(); this.files = data.files || []; } catch (e) { console.error("[FileBrowser] Error loading files:", e); } this.loading = false; this.render(); } private async loadCards() { try { const base = this.getApiBase(); const res = await fetch(`${base}/api/cards?space=${encodeURIComponent(this.space)}`); const data = await res.json(); this.cards = data.cards || []; } catch { this.cards = []; } } private getApiBase(): string { const path = window.location.pathname; const match = path.match(/^\/([^/]+)\/files/); return match ? `/${match[1]}/files` : ""; } private formatSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; } private formatDate(d: string): string { return new Date(d).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); } private mimeIcon(mime: string): string { if (mime?.startsWith("image/")) return "🖼️"; if (mime?.startsWith("video/")) return "🎥"; if (mime?.startsWith("audio/")) return "🎵"; if (mime?.includes("pdf")) return "📄"; if (mime?.includes("zip") || mime?.includes("tar") || mime?.includes("gz")) return "📦"; if (mime?.includes("text") || mime?.includes("json") || mime?.includes("xml")) return "📝"; return "📁"; } private cardTypeIcon(type: string): string { const icons: Record = { note: "📝", idea: "💡", task: "✅", reference: "🔗", quote: "💬", }; return icons[type] || "📝"; } private async handleUpload(e: Event) { e.preventDefault(); if (this.space === "demo") { alert("Upload is disabled in demo mode."); return; } const form = this.shadow.querySelector("#upload-form") as HTMLFormElement; if (!form) return; const fileInput = form.querySelector('input[type="file"]') as HTMLInputElement; if (!fileInput?.files?.length) return; const formData = new FormData(); formData.append("file", fileInput.files[0]); formData.append("space", this.space); const titleInput = form.querySelector('input[name="title"]') as HTMLInputElement; if (titleInput?.value) formData.append("title", titleInput.value); try { const base = this.getApiBase(); const res = await fetch(`${base}/api/files`, { method: "POST", body: formData }); if (res.ok) { form.reset(); this.loadFiles(); } else { const err = await res.json(); alert(`Upload failed: ${err.error || "Unknown error"}`); } } catch (e) { alert("Upload failed — network error"); } } private async handleDelete(fileId: string) { if (this.space === "demo") { alert("Delete is disabled in demo mode."); return; } if (!confirm("Delete this file?")) return; try { const base = this.getApiBase(); await fetch(`${base}/api/files/${fileId}`, { method: "DELETE" }); this.loadFiles(); } catch {} } private async handleShare(fileId: string) { if (this.space === "demo") { alert("Sharing is disabled in demo mode."); return; } try { const base = this.getApiBase(); const res = await fetch(`${base}/api/files/${fileId}/share`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ expires_in_hours: 72 }), }); const data = await res.json(); if (data.share?.url) { const fullUrl = `${window.location.origin}${this.getApiBase()}${data.share.url}`; await navigator.clipboard.writeText(fullUrl).catch(() => {}); alert(`Share link copied!\n${fullUrl}\nExpires in 72 hours.`); } } catch { alert("Failed to create share link"); } } private async handleCreateCard(e: Event) { e.preventDefault(); if (this.space === "demo") { alert("Creating cards is disabled in demo mode."); return; } const form = this.shadow.querySelector("#card-form") as HTMLFormElement; if (!form) return; const title = (form.querySelector('input[name="card-title"]') as HTMLInputElement)?.value; const body = (form.querySelector('textarea[name="card-body"]') as HTMLTextAreaElement)?.value; const cardType = (form.querySelector('select[name="card-type"]') as HTMLSelectElement)?.value; if (!title) return; try { const base = this.getApiBase(); const res = await fetch(`${base}/api/cards`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title, body, card_type: cardType, shared_space: this.space }), }); if (res.ok) { form.reset(); this.loadCards(); } } catch {} } private async handleDeleteCard(cardId: string) { if (this.space === "demo") { alert("Deleting cards is disabled in demo mode."); return; } if (!confirm("Delete this card?")) return; try { const base = this.getApiBase(); await fetch(`${base}/api/cards/${cardId}`, { method: "DELETE" }); this.loadCards(); } catch {} } private render() { const filesActive = this.tab === "files"; this.shadow.innerHTML = `
📁 Files
🎴 Memory Cards
${filesActive ? this.renderFilesTab() : this.renderCardsTab()} `; this.shadow.querySelectorAll(".tab-btn").forEach((btn) => { btn.addEventListener("click", () => { this.tab = (btn as HTMLElement).dataset.tab as "files" | "cards"; this.render(); }); }); const uploadForm = this.shadow.querySelector("#upload-form"); if (uploadForm) uploadForm.addEventListener("submit", (e) => this.handleUpload(e)); const cardForm = this.shadow.querySelector("#card-form"); if (cardForm) cardForm.addEventListener("submit", (e) => this.handleCreateCard(e)); this.shadow.querySelectorAll("[data-action]").forEach((btn) => { const action = (btn as HTMLElement).dataset.action!; const id = (btn as HTMLElement).dataset.id!; btn.addEventListener("click", () => { if (action === "delete") this.handleDelete(id); else if (action === "share") this.handleShare(id); else if (action === "delete-card") this.handleDeleteCard(id); else if (action === "download") { if (this.space === "demo") { alert("Download is disabled in demo mode."); return; } const base = this.getApiBase(); window.open(`${base}/api/files/${id}/download`, "_blank"); } }); }); } private renderFilesTab(): string { return `

Upload File

${this.loading ? '
Loading files...
' : ""} ${!this.loading && this.files.length === 0 ? '
No files yet. Upload one above.
' : ""} ${ !this.loading && this.files.length > 0 ? `
${this.files .map( (f) => `
${this.mimeIcon(f.mime_type)}
${this.esc(f.title || f.original_filename)}
${this.formatSize(f.file_size)} · ${this.formatDate(f.created_at)}
`, ) .join("")}
` : "" } `; } private renderCardsTab(): string { return `

New Memory Card

${this.cards.length === 0 ? '
No memory cards yet.
' : ""} ${ this.cards.length > 0 ? `
${this.cards .map( (c) => `
${this.cardTypeIcon(c.card_type)} ${this.esc(c.title)} ${c.card_type}
${c.body ? `
${this.esc(c.body)}
` : ""}
`, ) .join("")}
` : "" } `; } private esc(s: string): string { const d = document.createElement("div"); d.textContent = s || ""; return d.innerHTML; } } customElements.define("folk-file-browser", FolkFileBrowser);