/** * — 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(); } 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

Community books — read, share, and contribute

${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}/books/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}/books/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);