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
|
# Install production dependencies only
|
||||||
RUN bun install --production --frozen-lockfile
|
RUN bun install --production --frozen-lockfile
|
||||||
|
|
||||||
# Create data directory
|
# Create data directories
|
||||||
RUN mkdir -p /data/communities
|
RUN mkdir -p /data/communities /data/books
|
||||||
|
|
||||||
# Set environment
|
# Set environment
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV STORAGE_DIR=/data/communities
|
ENV STORAGE_DIR=/data/communities
|
||||||
|
ENV BOOKS_DIR=/data/books
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
|
||||||
# Data volume for persistence
|
# Data volumes for persistence
|
||||||
VOLUME /data/communities
|
VOLUME /data/communities
|
||||||
|
VOLUME /data/books
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
-- rSpace shared PostgreSQL — per-module schema isolation
|
-- rSpace shared PostgreSQL — per-module schema isolation
|
||||||
-- Each module owns its schema. Modules that don't need a DB skip this.
|
-- 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)
|
-- Module schemas (created on init, populated by module migrations)
|
||||||
CREATE SCHEMA IF NOT EXISTS rbooks;
|
CREATE SCHEMA IF NOT EXISTS rbooks;
|
||||||
CREATE SCHEMA IF NOT EXISTS rcart;
|
CREATE SCHEMA IF NOT EXISTS rcart;
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,11 @@ services:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- rspace-data:/data/communities
|
- rspace-data:/data/communities
|
||||||
|
- rspace-books:/data/books
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- STORAGE_DIR=/data/communities
|
- STORAGE_DIR=/data/communities
|
||||||
|
- BOOKS_DIR=/data/books
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
- INTERNAL_API_KEY=${INTERNAL_API_KEY}
|
- INTERNAL_API_KEY=${INTERNAL_API_KEY}
|
||||||
- DATABASE_URL=postgres://rspace:${POSTGRES_PASSWORD:-rspace}@rspace-db:5432/rspace
|
- DATABASE_URL=postgres://rspace:${POSTGRES_PASSWORD:-rspace}@rspace-db:5432/rspace
|
||||||
|
|
@ -55,6 +57,7 @@ services:
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
rspace-data:
|
rspace-data:
|
||||||
|
rspace-books:
|
||||||
rspace-pgdata:
|
rspace-pgdata:
|
||||||
|
|
||||||
networks:
|
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 ──
|
// ── Module system ──
|
||||||
import { registerModule, getAllModules, getModuleInfoList } from "../shared/module";
|
import { registerModule, getAllModules, getModuleInfoList } from "../shared/module";
|
||||||
import { canvasModule } from "../modules/canvas/mod";
|
import { canvasModule } from "../modules/canvas/mod";
|
||||||
|
import { booksModule } from "../modules/books/mod";
|
||||||
import { spaces } from "./spaces";
|
import { spaces } from "./spaces";
|
||||||
import { renderShell } from "./shell";
|
import { renderShell } from "./shell";
|
||||||
|
|
||||||
// Register modules
|
// Register modules
|
||||||
registerModule(canvasModule);
|
registerModule(canvasModule);
|
||||||
|
registerModule(booksModule);
|
||||||
|
|
||||||
// ── Config ──
|
// ── Config ──
|
||||||
const PORT = Number(process.env.PORT) || 3000;
|
const PORT = Number(process.env.PORT) || 3000;
|
||||||
|
|
@ -451,11 +453,15 @@ const server = Bun.serve<WSData>({
|
||||||
const response = await app.fetch(req);
|
const response = await app.fetch(req);
|
||||||
|
|
||||||
// If Hono returns 404, try serving canvas.html as SPA fallback
|
// 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/")) {
|
if (response.status === 404 && !url.pathname.startsWith("/api/")) {
|
||||||
// Check if this looks like a /:space/:module path
|
|
||||||
const parts = url.pathname.split("/").filter(Boolean);
|
const parts = url.pathname.split("/").filter(Boolean);
|
||||||
if (parts.length >= 1 && !parts[0].includes(".")) {
|
// Check if this is under a known module — if so, the module's 404 is authoritative
|
||||||
// Could be a space/module path — try canvas.html fallback
|
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");
|
const canvasHtml = await serveStatic("canvas.html");
|
||||||
if (canvasHtml) return canvasHtml;
|
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