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:
Jeff Emmett 2026-02-20 22:07:34 +00:00
parent eed7b2f151
commit b42179cff7
12 changed files with 1669 additions and 6 deletions

View File

@ -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

View File

@ -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;

View File

@ -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:

View File

@ -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);
}

View File

@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
}
customElements.define("folk-book-reader", FolkBookReader);

View File

@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
}
customElements.define("folk-book-shelf", FolkBookShelf);

View File

@ -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);

333
modules/books/mod.ts Normal file
View File

@ -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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// ── 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();

View File

@ -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}`);

View File

@ -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;

29
shared/db/pool.ts Normal file
View File

@ -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}`);
}

View File

@ -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"),
);
},
},
},