601 lines
18 KiB
TypeScript
601 lines
18 KiB
TypeScript
/**
|
|
* <folk-book-shelf> — 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: "amusing-ourselves-to-death", title: "Amusing Ourselves to Death", author: "Neil Postman", description: "A prophetic look at what happens when politics, journalism, education, and even religion become subject to the demands of entertainment. Originally published in 1985, this book remains incredibly relevant in the age of social media and constant digital distraction.", pdf_size_bytes: 2457600, page_count: 231, tags: ["media criticism", "society", "technology", "culture"], cover_color: "#1e3a5f", contributor_name: null, featured: true, view_count: 523, created_at: "2025-11-10" },
|
|
{ id: "b2", slug: "interference", title: "Interference: A Grand Scientific Musical Theory", author: "Richard Merrick", description: "A groundbreaking exploration of Harmonic Interference Theory - a set of principles explaining music perception using the physics of harmonic standing waves, connecting music theory to cymatics, Fibonacci sequences, and the fundamental patterns found throughout nature.", pdf_size_bytes: 8388608, page_count: 524, tags: ["music theory", "harmonics", "cymatics", "science", "perception"], cover_color: "#7c3aed", contributor_name: null, featured: true, view_count: 312, created_at: "2025-12-05" },
|
|
{ id: "b3", 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: "b4", 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: "b5", 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: false, view_count: 256, created_at: "2026-02-01" },
|
|
{ id: "b6", 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" },
|
|
];
|
|
}
|
|
|
|
attributeChangedCallback(name: string, _old: string, val: string) {
|
|
if (name === "space-slug") this._spaceSlug = val;
|
|
}
|
|
|
|
private get allTags(): string[] {
|
|
const tags = new Set<string>();
|
|
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 = `
|
|
<style>
|
|
:host {
|
|
display: block;
|
|
padding: 1.5rem;
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.rapp-nav { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; min-height: 36px; }
|
|
.rapp-nav__title { font-size: 15px; font-weight: 600; color: #e2e8f0; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.rapp-nav__actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
|
.rapp-nav__btn { padding: 6px 14px; border-radius: 6px; border: none; background: #4f46e5; color: #fff; font-weight: 600; cursor: pointer; font-size: 13px; }
|
|
.rapp-nav__btn:hover { background: #6366f1; }
|
|
|
|
.controls {
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.search-input {
|
|
flex: 1;
|
|
min-width: 200px;
|
|
padding: 0.5rem 0.75rem;
|
|
border: 1px solid #334155;
|
|
border-radius: 0.5rem;
|
|
background: #1e293b;
|
|
color: #f1f5f9;
|
|
font-size: 0.9rem;
|
|
}
|
|
.search-input::placeholder { color: #64748b; }
|
|
.search-input:focus { outline: none; border-color: #60a5fa; }
|
|
|
|
.tags {
|
|
display: flex;
|
|
gap: 0.375rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.tag {
|
|
padding: 0.25rem 0.625rem;
|
|
border-radius: 999px;
|
|
border: 1px solid #334155;
|
|
background: #1e293b;
|
|
color: #94a3b8;
|
|
font-size: 0.75rem;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
}
|
|
.tag:hover { border-color: #60a5fa; color: #e2e8f0; }
|
|
.tag.active { background: #1e3a5f; border-color: #60a5fa; color: #60a5fa; }
|
|
|
|
.upload-btn {
|
|
padding: 0.5rem 1rem;
|
|
border: none;
|
|
border-radius: 0.5rem;
|
|
background: #2563eb;
|
|
color: #fff;
|
|
font-size: 0.875rem;
|
|
cursor: pointer;
|
|
font-weight: 500;
|
|
transition: background 0.15s;
|
|
}
|
|
.upload-btn:hover { background: #1d4ed8; }
|
|
|
|
.grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
|
gap: 1.25rem;
|
|
}
|
|
|
|
.book-card {
|
|
display: flex;
|
|
flex-direction: column;
|
|
border-radius: 0.75rem;
|
|
overflow: hidden;
|
|
background: #1e293b;
|
|
border: 1px solid #334155;
|
|
cursor: pointer;
|
|
transition: transform 0.15s, border-color 0.15s;
|
|
text-decoration: none;
|
|
color: inherit;
|
|
}
|
|
.book-card:hover {
|
|
transform: translateY(-2px);
|
|
border-color: #60a5fa;
|
|
}
|
|
|
|
.book-cover {
|
|
aspect-ratio: 3/4;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 1rem;
|
|
position: relative;
|
|
}
|
|
|
|
.book-cover-title {
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
color: #fff;
|
|
text-align: center;
|
|
line-height: 1.3;
|
|
text-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 4;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.featured-badge {
|
|
position: absolute;
|
|
top: 0.5rem;
|
|
right: 0.5rem;
|
|
font-size: 0.7rem;
|
|
background: rgba(250, 204, 21, 0.9);
|
|
color: #1e293b;
|
|
padding: 0.125rem 0.375rem;
|
|
border-radius: 4px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.book-info {
|
|
padding: 0.75rem;
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.25rem;
|
|
}
|
|
|
|
.book-title {
|
|
font-size: 0.8rem;
|
|
font-weight: 600;
|
|
color: #e2e8f0;
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.book-author {
|
|
font-size: 0.75rem;
|
|
color: #94a3b8;
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 1;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.book-meta {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
font-size: 0.7rem;
|
|
color: #64748b;
|
|
margin-top: auto;
|
|
padding-top: 0.375rem;
|
|
}
|
|
|
|
.empty {
|
|
text-align: center;
|
|
padding: 3rem;
|
|
color: #64748b;
|
|
}
|
|
.empty h3 { margin: 0 0 0.5rem; color: #94a3b8; }
|
|
|
|
/* Upload modal */
|
|
.modal-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0,0,0,0.6);
|
|
z-index: 1000;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.modal-overlay[hidden] { display: none; }
|
|
|
|
.modal {
|
|
background: #1e293b;
|
|
border: 1px solid #334155;
|
|
border-radius: 1rem;
|
|
padding: 1.5rem;
|
|
width: 90%;
|
|
max-width: 480px;
|
|
max-height: 90vh;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.modal h3 {
|
|
margin: 0 0 1rem;
|
|
color: #f1f5f9;
|
|
font-size: 1.1rem;
|
|
}
|
|
|
|
.modal label {
|
|
display: block;
|
|
margin-bottom: 0.25rem;
|
|
font-size: 0.8rem;
|
|
color: #94a3b8;
|
|
}
|
|
|
|
.modal input,
|
|
.modal textarea {
|
|
width: 100%;
|
|
padding: 0.5rem 0.75rem;
|
|
margin-bottom: 0.75rem;
|
|
border: 1px solid #334155;
|
|
border-radius: 0.5rem;
|
|
background: #0f172a;
|
|
color: #f1f5f9;
|
|
font-size: 0.875rem;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.modal textarea { min-height: 80px; resize: vertical; }
|
|
.modal input:focus, .modal textarea:focus { outline: none; border-color: #60a5fa; }
|
|
|
|
.modal-actions {
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
justify-content: flex-end;
|
|
margin-top: 1rem;
|
|
}
|
|
|
|
.btn-cancel {
|
|
padding: 0.5rem 1rem;
|
|
border: 1px solid #334155;
|
|
border-radius: 0.5rem;
|
|
background: transparent;
|
|
color: #94a3b8;
|
|
cursor: pointer;
|
|
}
|
|
.btn-cancel:hover { border-color: #64748b; }
|
|
|
|
.btn-submit {
|
|
padding: 0.5rem 1rem;
|
|
border: none;
|
|
border-radius: 0.5rem;
|
|
background: #2563eb;
|
|
color: #fff;
|
|
cursor: pointer;
|
|
font-weight: 500;
|
|
}
|
|
.btn-submit:hover { background: #1d4ed8; }
|
|
.btn-submit:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
|
|
.drop-zone {
|
|
border: 2px dashed #334155;
|
|
border-radius: 0.75rem;
|
|
padding: 1.5rem;
|
|
text-align: center;
|
|
color: #64748b;
|
|
margin-bottom: 0.75rem;
|
|
cursor: pointer;
|
|
transition: border-color 0.15s;
|
|
}
|
|
.drop-zone:hover, .drop-zone.dragover { border-color: #60a5fa; color: #94a3b8; }
|
|
.drop-zone .selected { color: #60a5fa; font-weight: 500; }
|
|
|
|
.error-msg {
|
|
color: #f87171;
|
|
font-size: 0.8rem;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
</style>
|
|
|
|
<div class="rapp-nav">
|
|
<span class="rapp-nav__title">Library</span>
|
|
<div class="rapp-nav__actions">
|
|
<button class="rapp-nav__btn upload-btn">+ Add Book</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="controls">
|
|
<input class="search-input" type="text" placeholder="Search books..." />
|
|
</div>
|
|
|
|
${tags.length > 0 ? `
|
|
<div class="tags">
|
|
${tags.map((t) => `<span class="tag" data-tag="${t}">${t}</span>`).join("")}
|
|
</div>
|
|
` : ""}
|
|
|
|
${books.length === 0
|
|
? `<div class="empty">
|
|
<h3>No books yet</h3>
|
|
<p>Upload a PDF to share with the community</p>
|
|
</div>`
|
|
: `<div class="grid">
|
|
${books.map((b) => `
|
|
<a class="book-card" href="/${this._spaceSlug}/rbooks/read/${b.slug}">
|
|
<div class="book-cover" style="background:${b.cover_color}">
|
|
<span class="book-cover-title">${this.escapeHtml(b.title)}</span>
|
|
${b.featured ? '<span class="featured-badge">Featured</span>' : ""}
|
|
</div>
|
|
<div class="book-info">
|
|
<div class="book-title">${this.escapeHtml(b.title)}</div>
|
|
${b.author ? `<div class="book-author">${this.escapeHtml(b.author)}</div>` : ""}
|
|
<div class="book-meta">
|
|
<span>${this.formatSize(b.pdf_size_bytes)}</span>
|
|
<span>${b.view_count} views</span>
|
|
</div>
|
|
</div>
|
|
</a>
|
|
`).join("")}
|
|
</div>`
|
|
}
|
|
|
|
<div class="modal-overlay" hidden>
|
|
<div class="modal">
|
|
<h3>Share a Book</h3>
|
|
<div class="error-msg" hidden></div>
|
|
|
|
<div class="drop-zone">
|
|
<input type="file" accept="application/pdf" style="display:none" />
|
|
Drop a PDF here or click to browse
|
|
</div>
|
|
|
|
<label>Title *</label>
|
|
<input type="text" name="title" required />
|
|
|
|
<label>Author</label>
|
|
<input type="text" name="author" />
|
|
|
|
<label>Description</label>
|
|
<textarea name="description"></textarea>
|
|
|
|
<label>Tags (comma-separated)</label>
|
|
<input type="text" name="tags" placeholder="e.g. science, philosophy" />
|
|
|
|
<label>License</label>
|
|
<input type="text" name="license" value="CC BY-SA 4.0" />
|
|
|
|
<div class="modal-actions">
|
|
<button class="btn-cancel">Cancel</button>
|
|
<button class="btn-submit" disabled>Upload</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
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 = `<span class="selected">${file.name}</span>`;
|
|
if (titleInput.value) submitBtn.disabled = false;
|
|
}
|
|
});
|
|
|
|
fileInput?.addEventListener("change", () => {
|
|
if (fileInput.files?.[0]) {
|
|
selectedFile = fileInput.files[0];
|
|
dropZone.innerHTML = `<span class="selected">${selectedFile.name}</span>`;
|
|
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, ">").replace(/"/g, """);
|
|
}
|
|
}
|
|
|
|
customElements.define("folk-book-shelf", FolkBookShelf);
|