feat: add books module — Phase 2 port of rBooks to rSpace platform
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 <noreply@anthropic.com>
This commit is contained in:
parent
eed7b2f151
commit
b42179cff7
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -0,0 +1,523 @@
|
|||
/**
|
||||
* <folk-book-reader> — 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<void> {
|
||||
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<CachedBook | null> {
|
||||
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<void> {
|
||||
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()}
|
||||
<div class="loading">
|
||||
<div class="loading-spinner"></div>
|
||||
<div class="loading-status">${this._loadingStatus}</div>
|
||||
<div class="loading-bar">
|
||||
<div class="loading-fill" style="width:${this._loadingProgress}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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()}
|
||||
<div class="error">
|
||||
<h3>Failed to load book</h3>
|
||||
<p>${this.escapeHtml(this._error || "Unknown error")}</p>
|
||||
<button onclick="location.reload()">Retry</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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()}
|
||||
<div class="reader-container">
|
||||
<div class="reader-header">
|
||||
<div class="book-info">
|
||||
<span class="book-title">${this.escapeHtml(this._title)}</span>
|
||||
${this._author ? `<span class="book-author">by ${this.escapeHtml(this._author)}</span>` : ""}
|
||||
</div>
|
||||
<div class="page-counter">
|
||||
Page <span class="current-page">${this._currentPage + 1}</span> of ${this._numPages}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flipbook-wrapper">
|
||||
<button class="nav-btn nav-prev" title="Previous page">‹</button>
|
||||
<div class="flipbook-container" style="width:${pageW * 2}px; height:${pageH}px;"></div>
|
||||
<button class="nav-btn nav-next" title="Next page">›</button>
|
||||
</div>
|
||||
<div class="reader-footer">
|
||||
<button class="nav-text-btn" data-action="prev">← Previous</button>
|
||||
<button class="nav-text-btn" data-action="next">Next →</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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<void> {
|
||||
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 `<style>
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: calc(100vh - 52px);
|
||||
background: #0f172a;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: calc(100vh - 52px);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid #334155;
|
||||
border-top-color: #60a5fa;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.loading-status {
|
||||
color: #94a3b8;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.loading-bar {
|
||||
width: 200px;
|
||||
height: 4px;
|
||||
background: #1e293b;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.loading-fill {
|
||||
height: 100%;
|
||||
background: #60a5fa;
|
||||
transition: width 0.3s;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: calc(100vh - 52px);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.error h3 { color: #f87171; margin: 0; }
|
||||
.error p { color: #94a3b8; margin: 0; }
|
||||
.error button {
|
||||
margin-top: 1rem;
|
||||
padding: 0.5rem 1.5rem;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.5rem;
|
||||
background: #1e293b;
|
||||
color: #f1f5f9;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reader-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.reader-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.book-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.book-title {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.book-author {
|
||||
font-size: 0.8rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.page-counter {
|
||||
font-size: 0.85rem;
|
||||
color: #94a3b8;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.flipbook-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.flipbook-container {
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
width: 44px;
|
||||
height: 80px;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.5rem;
|
||||
background: #1e293b;
|
||||
color: #f1f5f9;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.nav-btn:hover { background: #334155; }
|
||||
|
||||
.reader-footer {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nav-text-btn {
|
||||
padding: 0.375rem 1rem;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.375rem;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.nav-text-btn:hover { border-color: #60a5fa; color: #f1f5f9; }
|
||||
</style>`;
|
||||
}
|
||||
|
||||
private escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("folk-book-reader", FolkBookReader);
|
||||
|
|
@ -0,0 +1,598 @@
|
|||
/**
|
||||
* <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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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: `
|
||||
<folk-book-shelf id="shelf"></folk-book-shelf>
|
||||
`,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "light",
|
||||
head: `<link rel="stylesheet" href="/modules/books/books.css">`,
|
||||
scripts: `
|
||||
<script type="module">
|
||||
import { FolkBookShelf } from '/modules/books/folk-book-shelf.js';
|
||||
const shelf = document.getElementById('shelf');
|
||||
shelf.books = ${booksJSON};
|
||||
shelf.spaceSlug = '${spaceSlug}';
|
||||
</script>
|
||||
`,
|
||||
});
|
||||
|
||||
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: `<div style="padding:3rem;text-align:center;color:#94a3b8;"><h2>Book not found</h2><p><a href="/${spaceSlug}/books" style="color:#60a5fa;">Back to library</a></p></div>`,
|
||||
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: `
|
||||
<folk-book-reader
|
||||
id="reader"
|
||||
pdf-url="${pdfUrl}"
|
||||
book-id="${book.slug}"
|
||||
title="${escapeAttr(book.title)}"
|
||||
author="${escapeAttr(book.author || '')}"
|
||||
></folk-book-reader>
|
||||
`,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
head: `<link rel="stylesheet" href="/modules/books/books.css">`,
|
||||
scripts: `
|
||||
<script type="module">
|
||||
import { FolkBookReader } from '/modules/books/folk-book-reader.js';
|
||||
</script>
|
||||
`,
|
||||
});
|
||||
|
||||
return c.html(html);
|
||||
});
|
||||
|
||||
// ── Initialize DB schema ──
|
||||
async function initDB(): Promise<void> {
|
||||
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, "<").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();
|
||||
|
|
@ -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}`);
|
||||
|
|
@ -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<WSData>({
|
|||
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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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}`);
|
||||
}
|
||||
|
|
@ -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"),
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue