599 lines
15 KiB
TypeScript
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
}
|
|
}
|
|
|
|
customElements.define("folk-book-shelf", FolkBookShelf);
|