rspace-online/modules/books/components/folk-book-shelf.ts

599 lines
15 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();
}
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;
}
.shelf-header {
margin-bottom: 1.5rem;
}
.shelf-header h2 {
margin: 0 0 0.5rem;
font-size: 1.5rem;
font-weight: 700;
color: #f1f5f9;
}
.shelf-header p {
margin: 0 0 1rem;
color: #94a3b8;
font-size: 0.9rem;
}
.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="shelf-header">
<h2>📚 Library</h2>
<p>Community books — read, share, and contribute</p>
</div>
<div class="controls">
<input class="search-input" type="text" placeholder="Search books..." />
<button class="upload-btn">+ Add Book</button>
</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}/books/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}/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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
}
customElements.define("folk-book-shelf", FolkBookShelf);