/** * — Book grid with search, tags, and upload. * * Displays community books in a responsive grid. Clicking a book * navigates to the flipbook reader. Authenticated users can upload. */ interface BookData { id: string; slug: string; title: string; author: string | null; description: string | null; pdf_size_bytes: number; page_count: number; tags: string[]; cover_color: string; contributor_name: string | null; featured: boolean; view_count: number; created_at: string; } export class FolkBookShelf extends HTMLElement { private _books: BookData[] = []; private _filtered: BookData[] = []; private _spaceSlug = "personal"; private _searchTerm = ""; private _selectedTag: string | null = null; static get observedAttributes() { return ["space-slug"]; } set books(val: BookData[]) { this._books = val; this._filtered = val; this.render(); } get books() { return this._books; } set spaceSlug(val: string) { this._spaceSlug = val; } connectedCallback() { this.attachShadow({ mode: "open" }); this.render(); if (this._spaceSlug === "demo" || this.getAttribute("space") === "demo") { this.loadDemoBooks(); } } private loadDemoBooks() { this.books = [ { id: "b1", slug: "governing-the-commons", title: "Governing the Commons", author: "Elinor Ostrom", description: "Analysis of collective action and the governance of common-pool resources", pdf_size_bytes: 2457600, page_count: 280, tags: ["economics", "governance"], cover_color: "#2563eb", contributor_name: "Community Library", featured: true, view_count: 342, created_at: "2026-01-15" }, { id: "b2", slug: "the-mushroom-at-the-end-of-the-world", title: "The Mushroom at the End of the World", author: "Anna Lowenhaupt Tsing", description: "On the possibility of life in capitalist ruins", pdf_size_bytes: 3145728, page_count: 352, tags: ["ecology", "anthropology"], cover_color: "#059669", contributor_name: null, featured: false, view_count: 187, created_at: "2026-01-20" }, { id: "b3", slug: "doughnut-economics", title: "Doughnut Economics", author: "Kate Raworth", description: "Seven ways to think like a 21st-century economist", pdf_size_bytes: 1887436, page_count: 320, tags: ["economics"], cover_color: "#d97706", contributor_name: "Reading Circle", featured: true, view_count: 256, created_at: "2026-02-01" }, { id: "b4", slug: "patterns-of-commoning", title: "Patterns of Commoning", author: "David Bollier & Silke Helfrich", description: "A collection of essays on commons-based peer production", pdf_size_bytes: 4194304, page_count: 416, tags: ["commons", "governance"], cover_color: "#7c3aed", contributor_name: null, featured: false, view_count: 98, created_at: "2026-02-05" }, { id: "b5", slug: "entangled-life", title: "Entangled Life", author: "Merlin Sheldrake", description: "How fungi make our worlds, change our minds, and shape our futures", pdf_size_bytes: 2621440, page_count: 368, tags: ["ecology", "science"], cover_color: "#0891b2", contributor_name: "Mycofi Lab", featured: false, view_count: 431, created_at: "2026-02-10" }, { id: "b6", slug: "free-fair-and-alive", title: "Free, Fair, and Alive", author: "David Bollier & Silke Helfrich", description: "The insurgent power of the commons", pdf_size_bytes: 3670016, page_count: 340, tags: ["commons", "politics"], cover_color: "#e11d48", contributor_name: null, featured: true, view_count: 175, created_at: "2026-02-12" } ]; } attributeChangedCallback(name: string, _old: string, val: string) { if (name === "space-slug") this._spaceSlug = val; } private get allTags(): string[] { const tags = new Set(); for (const b of this._books) { for (const t of b.tags || []) tags.add(t); } return Array.from(tags).sort(); } private applyFilters() { let result = this._books; if (this._searchTerm) { const term = this._searchTerm.toLowerCase(); result = result.filter( (b) => b.title.toLowerCase().includes(term) || (b.author && b.author.toLowerCase().includes(term)) || (b.description && b.description.toLowerCase().includes(term)) ); } if (this._selectedTag) { const tag = this._selectedTag; result = result.filter((b) => b.tags?.includes(tag)); } this._filtered = result; } private formatSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1048576) return `${(bytes / 1024).toFixed(0)} KB`; return `${(bytes / 1048576).toFixed(1)} MB`; } private render() { if (!this.shadowRoot) return; const tags = this.allTags; const books = this._filtered; this.shadowRoot.innerHTML = `
Library
${tags.length > 0 ? `
${tags.map((t) => `${t}`).join("")}
` : ""} ${books.length === 0 ? `

No books yet

Upload a PDF to share with the community

` : `` } `; this.bindEvents(); } private bindEvents() { if (!this.shadowRoot) return; // Search const searchInput = this.shadowRoot.querySelector(".search-input") as HTMLInputElement; searchInput?.addEventListener("input", () => { this._searchTerm = searchInput.value; this.applyFilters(); this.updateGrid(); }); // Tags this.shadowRoot.querySelectorAll(".tag").forEach((el) => { el.addEventListener("click", () => { const tag = (el as HTMLElement).dataset.tag!; if (this._selectedTag === tag) { this._selectedTag = null; el.classList.remove("active"); } else { this.shadowRoot!.querySelectorAll(".tag").forEach((t) => t.classList.remove("active")); this._selectedTag = tag; el.classList.add("active"); } this.applyFilters(); this.updateGrid(); }); }); // Upload modal const uploadBtn = this.shadowRoot.querySelector(".upload-btn"); const overlay = this.shadowRoot.querySelector(".modal-overlay") as HTMLElement; const cancelBtn = this.shadowRoot.querySelector(".btn-cancel"); const submitBtn = this.shadowRoot.querySelector(".btn-submit") as HTMLButtonElement; const dropZone = this.shadowRoot.querySelector(".drop-zone") as HTMLElement; const fileInput = this.shadowRoot.querySelector('input[type="file"]') as HTMLInputElement; const titleInput = this.shadowRoot.querySelector('input[name="title"]') as HTMLInputElement; const errorEl = this.shadowRoot.querySelector(".error-msg") as HTMLElement; let selectedFile: File | null = null; uploadBtn?.addEventListener("click", () => { overlay.hidden = false; }); cancelBtn?.addEventListener("click", () => { overlay.hidden = true; selectedFile = null; }); overlay?.addEventListener("click", (e) => { if (e.target === overlay) overlay.hidden = true; }); dropZone?.addEventListener("click", () => fileInput?.click()); dropZone?.addEventListener("dragover", (e) => { e.preventDefault(); dropZone.classList.add("dragover"); }); dropZone?.addEventListener("dragleave", () => dropZone.classList.remove("dragover")); dropZone?.addEventListener("drop", (e) => { e.preventDefault(); dropZone.classList.remove("dragover"); const file = (e as DragEvent).dataTransfer?.files[0]; if (file?.type === "application/pdf") { selectedFile = file; dropZone.innerHTML = `${file.name}`; if (titleInput.value) submitBtn.disabled = false; } }); fileInput?.addEventListener("change", () => { if (fileInput.files?.[0]) { selectedFile = fileInput.files[0]; dropZone.innerHTML = `${selectedFile.name}`; if (titleInput.value) submitBtn.disabled = false; } }); titleInput?.addEventListener("input", () => { submitBtn.disabled = !titleInput.value.trim() || !selectedFile; }); submitBtn?.addEventListener("click", async () => { if (!selectedFile || !titleInput.value.trim()) return; submitBtn.disabled = true; submitBtn.textContent = "Uploading..."; errorEl.hidden = true; const formData = new FormData(); formData.append("pdf", selectedFile); formData.append("title", titleInput.value.trim()); const authorInput = this.shadowRoot!.querySelector('input[name="author"]') as HTMLInputElement; const descInput = this.shadowRoot!.querySelector('textarea[name="description"]') as HTMLTextAreaElement; const tagsInput = this.shadowRoot!.querySelector('input[name="tags"]') as HTMLInputElement; const licenseInput = this.shadowRoot!.querySelector('input[name="license"]') as HTMLInputElement; if (authorInput.value) formData.append("author", authorInput.value); if (descInput.value) formData.append("description", descInput.value); if (tagsInput.value) formData.append("tags", tagsInput.value); if (licenseInput.value) formData.append("license", licenseInput.value); // Get auth token const token = localStorage.getItem("encryptid_token"); if (!token) { errorEl.textContent = "Please sign in first (use the identity button in the header)"; errorEl.hidden = false; submitBtn.disabled = false; submitBtn.textContent = "Upload"; return; } try { const res = await fetch(`/${this._spaceSlug}/rbooks/api/books`, { method: "POST", headers: { Authorization: `Bearer ${token}` }, body: formData, }); const data = await res.json(); if (!res.ok) { throw new Error(data.error || "Upload failed"); } // Navigate to the new book window.location.href = `/${this._spaceSlug}/rbooks/read/${data.slug}`; } catch (e: any) { errorEl.textContent = e.message; errorEl.hidden = false; submitBtn.disabled = false; submitBtn.textContent = "Upload"; } }); } private updateGrid() { // Re-render just the grid portion (lightweight update) this.render(); } private escapeHtml(s: string): string { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } } customElements.define("folk-book-shelf", FolkBookShelf);