From b42179cff77a0dada3a8ea63d9ee3076208e64ff Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 20 Feb 2026 22:07:34 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20add=20books=20module=20=E2=80=94=20Phas?= =?UTF-8?q?e=202=20port=20of=20rBooks=20to=20rSpace=20platform?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port rbooks-online (Next.js + React) as an rSpace module with Hono routes, vanilla web components, and shared PostgreSQL schema. Includes library grid (folk-book-shelf), flipbook PDF reader (folk-book-reader), upload with EncryptID auth, IndexedDB caching, and standalone deployment support. Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 8 +- db/init.sql | 3 + docker-compose.yml | 3 + modules/books/components/books.css | 17 + modules/books/components/folk-book-reader.ts | 523 ++++++++++++++++ modules/books/components/folk-book-shelf.ts | 598 +++++++++++++++++++ modules/books/db/schema.sql | 31 + modules/books/mod.ts | 333 +++++++++++ modules/books/standalone.ts | 71 +++ server/index.ts | 12 +- shared/db/pool.ts | 29 + vite.config.ts | 47 ++ 12 files changed, 1669 insertions(+), 6 deletions(-) create mode 100644 modules/books/components/books.css create mode 100644 modules/books/components/folk-book-reader.ts create mode 100644 modules/books/components/folk-book-shelf.ts create mode 100644 modules/books/db/schema.sql create mode 100644 modules/books/mod.ts create mode 100644 modules/books/standalone.ts create mode 100644 shared/db/pool.ts diff --git a/Dockerfile b/Dockerfile index c221efa..a53171d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,16 +32,18 @@ COPY --from=build /encryptid-sdk /encryptid-sdk # Install production dependencies only RUN bun install --production --frozen-lockfile -# Create data directory -RUN mkdir -p /data/communities +# Create data directories +RUN mkdir -p /data/communities /data/books # Set environment ENV NODE_ENV=production ENV STORAGE_DIR=/data/communities +ENV BOOKS_DIR=/data/books ENV PORT=3000 -# Data volume for persistence +# Data volumes for persistence VOLUME /data/communities +VOLUME /data/books EXPOSE 3000 diff --git a/db/init.sql b/db/init.sql index c338054..c3b03ad 100644 --- a/db/init.sql +++ b/db/init.sql @@ -1,6 +1,9 @@ -- rSpace shared PostgreSQL — per-module schema isolation -- Each module owns its schema. Modules that don't need a DB skip this. +-- Extensions available to all schemas +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + -- Module schemas (created on init, populated by module migrations) CREATE SCHEMA IF NOT EXISTS rbooks; CREATE SCHEMA IF NOT EXISTS rcart; diff --git a/docker-compose.yml b/docker-compose.yml index 53c3300..b2535e9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,9 +8,11 @@ services: restart: unless-stopped volumes: - rspace-data:/data/communities + - rspace-books:/data/books environment: - NODE_ENV=production - STORAGE_DIR=/data/communities + - BOOKS_DIR=/data/books - PORT=3000 - INTERNAL_API_KEY=${INTERNAL_API_KEY} - DATABASE_URL=postgres://rspace:${POSTGRES_PASSWORD:-rspace}@rspace-db:5432/rspace @@ -55,6 +57,7 @@ services: volumes: rspace-data: + rspace-books: rspace-pgdata: networks: diff --git a/modules/books/components/books.css b/modules/books/components/books.css new file mode 100644 index 0000000..5d7f6ed --- /dev/null +++ b/modules/books/components/books.css @@ -0,0 +1,17 @@ +/* Books module — additional styles for shell-wrapped pages */ + +/* Dark theme for reader page */ +body[data-theme="dark"] { + background: #0f172a; +} + +body[data-theme="dark"] .rstack-header { + background: #0f172a; + border-bottom-color: #1e293b; +} + +/* Library grid page */ +body[data-theme="light"] main { + background: #0f172a; + min-height: calc(100vh - 52px); +} diff --git a/modules/books/components/folk-book-reader.ts b/modules/books/components/folk-book-reader.ts new file mode 100644 index 0000000..923ad63 --- /dev/null +++ b/modules/books/components/folk-book-reader.ts @@ -0,0 +1,523 @@ +/** + * — Flipbook PDF reader using pdf.js + StPageFlip. + * + * Renders each PDF page to canvas, converts to images, then displays + * in a realistic page-flip animation. Caches rendered pages in IndexedDB. + * Saves reading position to localStorage. + * + * Attributes: + * pdf-url — URL to the PDF file + * book-id — Unique ID for caching/position tracking + * title — Book title (for display) + * author — Book author (for display) + */ + +// pdf.js is loaded from CDN; StPageFlip is imported from npm +// (we'll load both dynamically to avoid bundling issues) + +const PDFJS_CDN = "https://unpkg.com/pdfjs-dist@4.9.155/build/pdf.min.mjs"; +const PDFJS_WORKER_CDN = "https://unpkg.com/pdfjs-dist@4.9.155/build/pdf.worker.min.mjs"; +const STPAGEFLIP_CDN = "https://unpkg.com/page-flip@2.0.7/dist/js/page-flip.browser.js"; + +interface CachedBook { + images: string[]; + numPages: number; + aspectRatio: number; +} + +export class FolkBookReader extends HTMLElement { + private _pdfUrl = ""; + private _bookId = ""; + private _title = ""; + private _author = ""; + private _pageImages: string[] = []; + private _numPages = 0; + private _currentPage = 0; + private _aspectRatio = 1.414; // A4 default + private _isLoading = true; + private _loadingProgress = 0; + private _loadingStatus = "Preparing..."; + private _error: string | null = null; + private _flipBook: any = null; + private _db: IDBDatabase | null = null; + + static get observedAttributes() { + return ["pdf-url", "book-id", "title", "author"]; + } + + attributeChangedCallback(name: string, _old: string, val: string) { + if (name === "pdf-url") this._pdfUrl = val; + else if (name === "book-id") this._bookId = val; + else if (name === "title") this._title = val; + else if (name === "author") this._author = val; + } + + async connectedCallback() { + this._pdfUrl = this.getAttribute("pdf-url") || ""; + this._bookId = this.getAttribute("book-id") || ""; + this._title = this.getAttribute("title") || ""; + this._author = this.getAttribute("author") || ""; + + this.attachShadow({ mode: "open" }); + this.renderLoading(); + + // Restore reading position + const savedPage = localStorage.getItem(`book-position-${this._bookId}`); + if (savedPage) this._currentPage = parseInt(savedPage) || 0; + + try { + await this.openDB(); + const cached = await this.loadFromCache(); + + if (cached) { + this._pageImages = cached.images; + this._numPages = cached.numPages; + this._aspectRatio = cached.aspectRatio; + this._isLoading = false; + this.renderReader(); + } else { + await this.loadAndRenderPDF(); + } + } catch (e: any) { + this._error = e.message || "Failed to load book"; + this._isLoading = false; + this.renderError(); + } + } + + disconnectedCallback() { + // Save position + localStorage.setItem(`book-position-${this._bookId}`, String(this._currentPage)); + this._flipBook?.destroy(); + this._db?.close(); + } + + // ── IndexedDB cache ── + + private openDB(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open("rspace-books-cache", 1); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains("book-images")) { + db.createObjectStore("book-images"); + } + }; + req.onsuccess = () => { this._db = req.result; resolve(); }; + req.onerror = () => reject(req.error); + }); + } + + private loadFromCache(): Promise { + return new Promise((resolve) => { + if (!this._db) { resolve(null); return; } + const tx = this._db.transaction("book-images", "readonly"); + const store = tx.objectStore("book-images"); + const req = store.get(this._bookId); + req.onsuccess = () => resolve(req.result || null); + req.onerror = () => resolve(null); + }); + } + + private saveToCache(data: CachedBook): Promise { + return new Promise((resolve) => { + if (!this._db) { resolve(); return; } + const tx = this._db.transaction("book-images", "readwrite"); + const store = tx.objectStore("book-images"); + store.put(data, this._bookId); + tx.oncomplete = () => resolve(); + tx.onerror = () => resolve(); + }); + } + + // ── PDF rendering ── + + private async loadAndRenderPDF() { + this._loadingStatus = "Loading PDF.js..."; + this.updateLoadingUI(); + + // Load pdf.js + const pdfjsLib = await import(/* @vite-ignore */ PDFJS_CDN); + pdfjsLib.GlobalWorkerOptions.workerSrc = PDFJS_WORKER_CDN; + + this._loadingStatus = "Downloading PDF..."; + this.updateLoadingUI(); + + const pdf = await pdfjsLib.getDocument(this._pdfUrl).promise; + this._numPages = pdf.numPages; + this._pageImages = []; + + // Get aspect ratio from first page + const firstPage = await pdf.getPage(1); + const viewport = firstPage.getViewport({ scale: 1 }); + this._aspectRatio = viewport.width / viewport.height; + + const scale = 2; // 2x for quality + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d")!; + + for (let i = 1; i <= pdf.numPages; i++) { + this._loadingStatus = `Rendering page ${i} of ${pdf.numPages}...`; + this._loadingProgress = Math.round((i / pdf.numPages) * 100); + this.updateLoadingUI(); + + const page = await pdf.getPage(i); + const vp = page.getViewport({ scale }); + canvas.width = vp.width; + canvas.height = vp.height; + ctx.clearRect(0, 0, canvas.width, canvas.height); + + await page.render({ canvasContext: ctx, viewport: vp }).promise; + this._pageImages.push(canvas.toDataURL("image/jpeg", 0.85)); + } + + // Cache + await this.saveToCache({ + images: this._pageImages, + numPages: this._numPages, + aspectRatio: this._aspectRatio, + }); + + this._isLoading = false; + this.renderReader(); + } + + // ── UI rendering ── + + private renderLoading() { + if (!this.shadowRoot) return; + this.shadowRoot.innerHTML = ` + ${this.getStyles()} +
+
+
${this._loadingStatus}
+
+
+
+
+ `; + } + + private updateLoadingUI() { + if (!this.shadowRoot) return; + const status = this.shadowRoot.querySelector(".loading-status"); + const fill = this.shadowRoot.querySelector(".loading-fill") as HTMLElement; + if (status) status.textContent = this._loadingStatus; + if (fill) fill.style.width = `${this._loadingProgress}%`; + } + + private renderError() { + if (!this.shadowRoot) return; + this.shadowRoot.innerHTML = ` + ${this.getStyles()} +
+

Failed to load book

+

${this.escapeHtml(this._error || "Unknown error")}

+ +
+ `; + } + + private renderReader() { + if (!this.shadowRoot) return; + + // Calculate dimensions + const maxW = Math.min(window.innerWidth * 0.9, 800); + const maxH = window.innerHeight - 160; + let pageW = maxW / 2; + let pageH = pageW / this._aspectRatio; + if (pageH > maxH) { + pageH = maxH; + pageW = pageH * this._aspectRatio; + } + + this.shadowRoot.innerHTML = ` + ${this.getStyles()} +
+
+
+ ${this.escapeHtml(this._title)} + ${this._author ? `by ${this.escapeHtml(this._author)}` : ""} +
+
+ Page ${this._currentPage + 1} of ${this._numPages} +
+
+
+ +
+ +
+ +
+ `; + + this.initFlipbook(pageW, pageH); + this.bindReaderEvents(); + } + + private async initFlipbook(pageW: number, pageH: number) { + if (!this.shadowRoot) return; + + const container = this.shadowRoot.querySelector(".flipbook-container") as HTMLElement; + if (!container) return; + + // Load StPageFlip + await this.loadStPageFlip(); + + const PageFlip = (window as any).St?.PageFlip; + if (!PageFlip) { + console.error("StPageFlip not loaded"); + return; + } + + this._flipBook = new PageFlip(container, { + width: Math.round(pageW), + height: Math.round(pageH), + showCover: true, + maxShadowOpacity: 0.5, + mobileScrollSupport: false, + useMouseEvents: true, + swipeDistance: 30, + clickEventForward: false, + flippingTime: 600, + startPage: this._currentPage, + }); + + // Create page elements + const pages: HTMLElement[] = []; + for (let i = 0; i < this._pageImages.length; i++) { + const page = document.createElement("div"); + page.className = "page-content"; + page.style.cssText = ` + width: 100%; + height: 100%; + background-image: url(${this._pageImages[i]}); + background-size: cover; + background-position: center; + `; + pages.push(page); + } + + this._flipBook.loadFromHTML(pages); + + this._flipBook.on("flip", (e: any) => { + this._currentPage = e.data; + this.updatePageCounter(); + localStorage.setItem(`book-position-${this._bookId}`, String(this._currentPage)); + }); + } + + private loadStPageFlip(): Promise { + return new Promise((resolve, reject) => { + if ((window as any).St?.PageFlip) { resolve(); return; } + const script = document.createElement("script"); + script.src = STPAGEFLIP_CDN; + script.onload = () => resolve(); + script.onerror = () => reject(new Error("Failed to load StPageFlip")); + document.head.appendChild(script); + }); + } + + private bindReaderEvents() { + if (!this.shadowRoot) return; + + // Nav buttons + this.shadowRoot.querySelector(".nav-prev")?.addEventListener("click", () => { + this._flipBook?.flipPrev(); + }); + this.shadowRoot.querySelector(".nav-next")?.addEventListener("click", () => { + this._flipBook?.flipNext(); + }); + this.shadowRoot.querySelectorAll(".nav-text-btn").forEach((btn) => { + btn.addEventListener("click", () => { + const action = (btn as HTMLElement).dataset.action; + if (action === "prev") this._flipBook?.flipPrev(); + else if (action === "next") this._flipBook?.flipNext(); + }); + }); + + // Keyboard nav + document.addEventListener("keydown", (e) => { + if (e.key === "ArrowLeft") this._flipBook?.flipPrev(); + else if (e.key === "ArrowRight") this._flipBook?.flipNext(); + }); + + // Resize handler + let resizeTimer: number; + window.addEventListener("resize", () => { + clearTimeout(resizeTimer); + resizeTimer = window.setTimeout(() => this.renderReader(), 250); + }); + } + + private updatePageCounter() { + if (!this.shadowRoot) return; + const el = this.shadowRoot.querySelector(".current-page"); + if (el) el.textContent = String(this._currentPage + 1); + } + + private getStyles(): string { + return ``; + } + + private escapeHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + } +} + +customElements.define("folk-book-reader", FolkBookReader); diff --git a/modules/books/components/folk-book-shelf.ts b/modules/books/components/folk-book-shelf.ts new file mode 100644 index 0000000..8051a56 --- /dev/null +++ b/modules/books/components/folk-book-shelf.ts @@ -0,0 +1,598 @@ +/** + * — 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); diff --git a/modules/books/db/schema.sql b/modules/books/db/schema.sql new file mode 100644 index 0000000..809f624 --- /dev/null +++ b/modules/books/db/schema.sql @@ -0,0 +1,31 @@ +-- rBooks schema — community PDF library +-- Runs inside the `rbooks` schema (set by migration runner) + +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +CREATE TABLE IF NOT EXISTS books ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + slug TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + author TEXT, + description TEXT, + pdf_path TEXT NOT NULL, + pdf_size_bytes BIGINT DEFAULT 0, + page_count INTEGER DEFAULT 0, + tags TEXT[] DEFAULT '{}', + license TEXT DEFAULT 'CC BY-SA 4.0', + cover_color TEXT DEFAULT '#334155', + contributor_id TEXT, + contributor_name TEXT, + status TEXT NOT NULL DEFAULT 'published', + featured BOOLEAN DEFAULT FALSE, + view_count INTEGER DEFAULT 0, + download_count INTEGER DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_books_status ON books (status) WHERE status = 'published'; +CREATE INDEX IF NOT EXISTS idx_books_slug ON books (slug); +CREATE INDEX IF NOT EXISTS idx_books_featured ON books (featured) WHERE featured = TRUE; +CREATE INDEX IF NOT EXISTS idx_books_created ON books (created_at DESC); diff --git a/modules/books/mod.ts b/modules/books/mod.ts new file mode 100644 index 0000000..d33634c --- /dev/null +++ b/modules/books/mod.ts @@ -0,0 +1,333 @@ +/** + * Books module — community PDF library with flipbook reader. + * + * Ported from rbooks-online (Next.js) to Hono routes. + * Routes are relative to mount point (/:space/books in unified, / in standalone). + */ + +import { Hono } from "hono"; +import { resolve } from "node:path"; +import { mkdir, readFile } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { sql } from "../../shared/db/pool"; +import { renderShell } from "../../server/shell"; +import { getModuleInfoList } from "../../shared/module"; +import type { RSpaceModule } from "../../shared/module"; +import { + verifyEncryptIDToken, + extractToken, +} from "@encryptid/sdk/server"; + +const BOOKS_DIR = process.env.BOOKS_DIR || "/data/books"; + +// ── Types ── + +export interface BookRow { + id: string; + slug: string; + title: string; + author: string | null; + description: string | null; + pdf_path: string; + pdf_size_bytes: number; + page_count: number; + tags: string[]; + license: string; + cover_color: string; + contributor_id: string | null; + contributor_name: string | null; + status: string; + featured: boolean; + view_count: number; + download_count: number; + created_at: string; + updated_at: string; +} + +// ── Helpers ── + +function slugify(text: string): string { + return text + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 80); +} + +// ── Routes ── + +const routes = new Hono(); + +// ── API: List books ── +routes.get("/api/books", async (c) => { + const search = c.req.query("search"); + const tag = c.req.query("tag"); + const limit = Math.min(parseInt(c.req.query("limit") || "50"), 100); + const offset = parseInt(c.req.query("offset") || "0"); + + let query = `SELECT id, slug, title, author, description, pdf_size_bytes, + page_count, tags, cover_color, contributor_name, featured, + view_count, created_at + FROM rbooks.books WHERE status = 'published'`; + const params: (string | number)[] = []; + + if (search) { + params.push(`%${search}%`); + query += ` AND (title ILIKE $${params.length} OR author ILIKE $${params.length} OR description ILIKE $${params.length})`; + } + if (tag) { + params.push(tag); + query += ` AND $${params.length} = ANY(tags)`; + } + + query += ` ORDER BY featured DESC, created_at DESC`; + params.push(limit); + query += ` LIMIT $${params.length}`; + params.push(offset); + query += ` OFFSET $${params.length}`; + + const rows = await sql.unsafe(query, params); + return c.json({ books: rows }); +}); + +// ── API: Upload book ── +routes.post("/api/books", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + + let claims; + try { + claims = await verifyEncryptIDToken(token); + } catch { + return c.json({ error: "Invalid token" }, 401); + } + + const formData = await c.req.formData(); + const file = formData.get("pdf") as File | null; + const title = (formData.get("title") as string || "").trim(); + const author = (formData.get("author") as string || "").trim() || null; + const description = (formData.get("description") as string || "").trim() || null; + const tagsRaw = (formData.get("tags") as string || "").trim(); + const license = (formData.get("license") as string || "CC BY-SA 4.0").trim(); + + if (!file || file.type !== "application/pdf") { + return c.json({ error: "PDF file required" }, 400); + } + if (!title) { + return c.json({ error: "Title required" }, 400); + } + + const tags = tagsRaw ? tagsRaw.split(",").map((t) => t.trim()).filter(Boolean) : []; + const shortId = randomUUID().slice(0, 8); + let slug = slugify(title); + + // Check slug collision + const existing = await sql.unsafe( + `SELECT 1 FROM rbooks.books WHERE slug = $1`, [slug] + ); + if (existing.length > 0) { + slug = `${slug}-${shortId}`; + } + + // Save PDF to disk + await mkdir(BOOKS_DIR, { recursive: true }); + const filename = `${slug}.pdf`; + const filepath = resolve(BOOKS_DIR, filename); + const buffer = Buffer.from(await file.arrayBuffer()); + await Bun.write(filepath, buffer); + + // Insert into DB + const rows = await sql.unsafe( + `INSERT INTO rbooks.books (slug, title, author, description, pdf_path, pdf_size_bytes, tags, license, contributor_id, contributor_name) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING id, slug, title, author, description, tags, created_at`, + [slug, title, author, description, filename, buffer.length, tags, license, claims.sub, claims.username || null] + ); + + return c.json(rows[0], 201); +}); + +// ── API: Get book details ── +routes.get("/api/books/:id", async (c) => { + const id = c.req.param("id"); + + const rows = await sql.unsafe( + `SELECT * FROM rbooks.books WHERE (slug = $1 OR id::text = $1) AND status = 'published'`, + [id] + ); + + if (rows.length === 0) return c.json({ error: "Book not found" }, 404); + + // Increment view count + await sql.unsafe( + `UPDATE rbooks.books SET view_count = view_count + 1 WHERE id = $1`, + [rows[0].id] + ); + + return c.json(rows[0]); +}); + +// ── API: Serve PDF ── +routes.get("/api/books/:id/pdf", async (c) => { + const id = c.req.param("id"); + + const rows = await sql.unsafe( + `SELECT id, slug, title, pdf_path FROM rbooks.books WHERE (slug = $1 OR id::text = $1) AND status = 'published'`, + [id] + ); + + if (rows.length === 0) return c.json({ error: "Book not found" }, 404); + + const book = rows[0]; + const filepath = resolve(BOOKS_DIR, book.pdf_path); + const file = Bun.file(filepath); + + if (!(await file.exists())) { + return c.json({ error: "PDF file not found" }, 404); + } + + // Increment download count + await sql.unsafe( + `UPDATE rbooks.books SET download_count = download_count + 1 WHERE id = $1`, + [book.id] + ); + + return new Response(file, { + headers: { + "Content-Type": "application/pdf", + "Content-Disposition": `inline; filename="${book.slug}.pdf"`, + "Content-Length": String(file.size), + }, + }); +}); + +// ── Page: Library (book grid) ── +routes.get("/", async (c) => { + const spaceSlug = c.req.param("space") || "personal"; + + // Fetch books for the library page + const rows = await sql.unsafe( + `SELECT id, slug, title, author, description, pdf_size_bytes, page_count, tags, + cover_color, contributor_name, featured, view_count, created_at + FROM rbooks.books WHERE status = 'published' + ORDER BY featured DESC, created_at DESC LIMIT 50` + ); + + const booksJSON = JSON.stringify(rows); + + const html = renderShell({ + title: `${spaceSlug} — Library | rSpace`, + moduleId: "books", + spaceSlug, + body: ` + + `, + modules: getModuleInfoList(), + theme: "light", + head: ``, + scripts: ` + + `, + }); + + return c.html(html); +}); + +// ── Page: Book reader ── +routes.get("/read/:id", async (c) => { + const spaceSlug = c.req.param("space") || "personal"; + const id = c.req.param("id"); + + const rows = await sql.unsafe( + `SELECT * FROM rbooks.books WHERE (slug = $1 OR id::text = $1) AND status = 'published'`, + [id] + ); + + if (rows.length === 0) { + const html = renderShell({ + title: "Book not found | rSpace", + moduleId: "books", + spaceSlug, + body: `

Book not found

Back to library

`, + modules: getModuleInfoList(), + }); + return c.html(html, 404); + } + + const book = rows[0]; + + // Increment view count + await sql.unsafe( + `UPDATE rbooks.books SET view_count = view_count + 1 WHERE id = $1`, + [book.id] + ); + + // Build the PDF URL relative to this module's mount point + const pdfUrl = `/${spaceSlug}/books/api/books/${book.slug}/pdf`; + + const html = renderShell({ + title: `${book.title} | rSpace`, + moduleId: "books", + spaceSlug, + body: ` + + `, + modules: getModuleInfoList(), + theme: "dark", + head: ``, + scripts: ` + + `, + }); + + return c.html(html); +}); + +// ── Initialize DB schema ── +async function initDB(): Promise { + try { + const schemaPath = resolve(import.meta.dir, "db/schema.sql"); + const schemaSql = await readFile(schemaPath, "utf-8"); + await sql.unsafe(`SET search_path TO rbooks, public`); + await sql.unsafe(schemaSql); + await sql.unsafe(`SET search_path TO public`); + console.log("[Books] Database schema initialized"); + } catch (e) { + console.error("[Books] Schema init failed:", e); + } +} + +function escapeAttr(s: string): string { + return s.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); +} + +// ── Module export ── + +export const booksModule: RSpaceModule = { + id: "books", + name: "rBooks", + icon: "📚", + description: "Community PDF library with flipbook reader", + routes, + standaloneDomain: "rbooks.online", + + async onSpaceCreate(spaceSlug: string) { + // Books are global, not space-scoped (for now). No-op. + }, +}; + +// Run schema init on import +initDB(); diff --git a/modules/books/standalone.ts b/modules/books/standalone.ts new file mode 100644 index 0000000..7b9df85 --- /dev/null +++ b/modules/books/standalone.ts @@ -0,0 +1,71 @@ +/** + * rBooks standalone server — independent deployment at rbooks.online. + * + * Wraps the books module routes in a minimal Hono server with + * standalone shell (just EncryptID identity, no app/space switcher). + * + * Usage: bun run modules/books/standalone.ts + */ + +import { Hono } from "hono"; +import { cors } from "hono/cors"; +import { resolve } from "node:path"; +import { booksModule } from "./mod"; +import { renderStandaloneShell } from "../../server/shell"; + +const PORT = Number(process.env.PORT) || 3000; +const DIST_DIR = resolve(import.meta.dir, "../../dist"); + +const app = new Hono(); + +app.use("/api/*", cors()); + +// WebAuthn related origins (passkey sharing with rspace.online) +app.get("/.well-known/webauthn", (c) => { + return c.json( + { origins: ["https://rspace.online"] }, + 200, + { "Access-Control-Allow-Origin": "*", "Cache-Control": "public, max-age=3600" } + ); +}); + +// Mount books module routes at root +app.route("/", booksModule.routes); + +// Static asset serving +function getContentType(path: string): string { + if (path.endsWith(".html")) return "text/html"; + if (path.endsWith(".js")) return "application/javascript"; + if (path.endsWith(".css")) return "text/css"; + if (path.endsWith(".json")) return "application/json"; + if (path.endsWith(".svg")) return "image/svg+xml"; + if (path.endsWith(".png")) return "image/png"; + if (path.endsWith(".jpg") || path.endsWith(".jpeg")) return "image/jpeg"; + if (path.endsWith(".ico")) return "image/x-icon"; + return "application/octet-stream"; +} + +Bun.serve({ + port: PORT, + + async fetch(req) { + const url = new URL(req.url); + + // Static assets + if (url.pathname !== "/" && !url.pathname.startsWith("/api/")) { + const assetPath = url.pathname.slice(1); + if (assetPath.includes(".")) { + const filePath = resolve(DIST_DIR, assetPath); + const file = Bun.file(filePath); + if (await file.exists()) { + return new Response(file, { headers: { "Content-Type": getContentType(assetPath) } }); + } + } + } + + // Hono handles all routes + return app.fetch(req); + }, +}); + +console.log(`rBooks standalone server running on http://localhost:${PORT}`); diff --git a/server/index.ts b/server/index.ts index d609345..11b059a 100644 --- a/server/index.ts +++ b/server/index.ts @@ -39,11 +39,13 @@ import type { EncryptIDClaims, SpaceAuthConfig } from "@encryptid/sdk/server"; // ── Module system ── import { registerModule, getAllModules, getModuleInfoList } from "../shared/module"; import { canvasModule } from "../modules/canvas/mod"; +import { booksModule } from "../modules/books/mod"; import { spaces } from "./spaces"; import { renderShell } from "./shell"; // Register modules registerModule(canvasModule); +registerModule(booksModule); // ── Config ── const PORT = Number(process.env.PORT) || 3000; @@ -451,11 +453,15 @@ const server = Bun.serve({ const response = await app.fetch(req); // If Hono returns 404, try serving canvas.html as SPA fallback + // But only for paths that don't match a known module route if (response.status === 404 && !url.pathname.startsWith("/api/")) { - // Check if this looks like a /:space/:module path const parts = url.pathname.split("/").filter(Boolean); - if (parts.length >= 1 && !parts[0].includes(".")) { - // Could be a space/module path — try canvas.html fallback + // Check if this is under a known module — if so, the module's 404 is authoritative + const knownModuleIds = getAllModules().map((m) => m.id); + const isModulePath = parts.length >= 2 && knownModuleIds.includes(parts[1]); + + if (!isModulePath && parts.length >= 1 && !parts[0].includes(".")) { + // Not a module path — could be a canvas SPA route, try fallback const canvasHtml = await serveStatic("canvas.html"); if (canvasHtml) return canvasHtml; diff --git a/shared/db/pool.ts b/shared/db/pool.ts new file mode 100644 index 0000000..6a7c509 --- /dev/null +++ b/shared/db/pool.ts @@ -0,0 +1,29 @@ +/** + * Shared PostgreSQL connection pool for all rSpace modules. + * + * Uses the `postgres` (postgres.js) library already in package.json. + * Each module uses its own schema (SET search_path) but shares this pool. + */ + +import postgres from "postgres"; + +const DATABASE_URL = + process.env.DATABASE_URL || "postgres://rspace:rspace@rspace-db:5432/rspace"; + +/** Global shared connection */ +export const sql = postgres(DATABASE_URL, { + max: 20, + idle_timeout: 30, + connect_timeout: 10, +}); + +/** + * Run a module's schema migration SQL. + * Called once at startup for modules that need a database. + */ +export async function runMigration(schema: string, migrationSQL: string): Promise { + await sql.unsafe(`SET search_path TO ${schema}, public`); + await sql.unsafe(migrationSQL); + await sql.unsafe(`SET search_path TO public`); + console.log(`[DB] Migration complete for schema: ${schema}`); +} diff --git a/vite.config.ts b/vite.config.ts index 9853669..802b2b8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -58,6 +58,53 @@ export default defineConfig({ }, }, }); + + // Build books module components + await build({ + configFile: false, + root: resolve(__dirname, "modules/books/components"), + build: { + emptyOutDir: false, + outDir: resolve(__dirname, "dist/modules/books"), + lib: { + entry: resolve(__dirname, "modules/books/components/folk-book-shelf.ts"), + formats: ["es"], + fileName: () => "folk-book-shelf.js", + }, + rollupOptions: { + output: { + entryFileNames: "folk-book-shelf.js", + }, + }, + }, + }); + + await build({ + configFile: false, + root: resolve(__dirname, "modules/books/components"), + build: { + emptyOutDir: false, + outDir: resolve(__dirname, "dist/modules/books"), + lib: { + entry: resolve(__dirname, "modules/books/components/folk-book-reader.ts"), + formats: ["es"], + fileName: () => "folk-book-reader.js", + }, + rollupOptions: { + output: { + entryFileNames: "folk-book-reader.js", + }, + }, + }, + }); + + // Copy books CSS + const { copyFileSync, mkdirSync } = await import("node:fs"); + mkdirSync(resolve(__dirname, "dist/modules/books"), { recursive: true }); + copyFileSync( + resolve(__dirname, "modules/books/components/books.css"), + resolve(__dirname, "dist/modules/books/books.css"), + ); }, }, },