Merge branch 'dev'
This commit is contained in:
commit
afc66ac4c1
|
|
@ -5,6 +5,9 @@
|
|||
* navigates to the flipbook reader. Authenticated users can upload.
|
||||
*/
|
||||
|
||||
import { booksCatalogSchema, booksCatalogDocId, type BooksCatalogDoc } from "../schemas";
|
||||
import type { DocumentId } from "../../../shared/local-first/document";
|
||||
|
||||
interface BookData {
|
||||
id: string;
|
||||
slug: string;
|
||||
|
|
@ -27,6 +30,7 @@ export class FolkBookShelf extends HTMLElement {
|
|||
private _spaceSlug = "personal";
|
||||
private _searchTerm = "";
|
||||
private _selectedTag: string | null = null;
|
||||
private _offlineUnsub: (() => void) | null = null;
|
||||
|
||||
static get observedAttributes() {
|
||||
return ["space-slug"];
|
||||
|
|
@ -48,8 +52,49 @@ export class FolkBookShelf extends HTMLElement {
|
|||
|
||||
connectedCallback() {
|
||||
this.attachShadow({ mode: "open" });
|
||||
this._spaceSlug = this.getAttribute("space-slug") || this._spaceSlug;
|
||||
this.render();
|
||||
if (this._spaceSlug === "demo" || this.getAttribute("space") === "demo") { this.loadDemoBooks(); }
|
||||
if (this._spaceSlug === "demo" || this.getAttribute("space") === "demo") {
|
||||
this.loadDemoBooks();
|
||||
} else {
|
||||
this.subscribeOffline();
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this._offlineUnsub?.();
|
||||
this._offlineUnsub = null;
|
||||
}
|
||||
|
||||
private async subscribeOffline() {
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (!runtime?.isInitialized) return;
|
||||
|
||||
try {
|
||||
const docId = booksCatalogDocId(this._spaceSlug) as DocumentId;
|
||||
const doc = await runtime.subscribe(docId, booksCatalogSchema);
|
||||
this.renderFromDoc(doc);
|
||||
|
||||
this._offlineUnsub = runtime.onChange(docId, (updated: any) => {
|
||||
this.renderFromDoc(updated);
|
||||
});
|
||||
} catch {
|
||||
// Runtime unavailable — server-side hydration handles data
|
||||
}
|
||||
}
|
||||
|
||||
private renderFromDoc(doc: BooksCatalogDoc) {
|
||||
if (!doc?.items || Object.keys(doc.items).length === 0) return;
|
||||
// Only populate from doc if not already hydrated by server
|
||||
if (this._books.length > 0) return;
|
||||
this.books = Object.values(doc.items).map(b => ({
|
||||
id: b.id, slug: b.slug, title: b.title, author: b.author,
|
||||
description: b.description, pdf_size_bytes: b.pdfSizeBytes,
|
||||
page_count: b.pageCount, tags: b.tags || [],
|
||||
cover_color: b.coverColor || '#333',
|
||||
contributor_name: b.contributorName, featured: b.featured,
|
||||
view_count: b.viewCount, created_at: new Date(b.createdAt).toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
private loadDemoBooks() {
|
||||
|
|
|
|||
|
|
@ -100,6 +100,10 @@ function leafletZoomToSpatial(zoom: number): number {
|
|||
if (zoom <= 15) return 6; if (zoom <= 17) return 7; return 8;
|
||||
}
|
||||
|
||||
// ── Offline-first imports ──
|
||||
import { calendarSchema, calendarDocId, type CalendarDoc } from "../schemas";
|
||||
import type { DocumentId } from "../../../shared/local-first/document";
|
||||
|
||||
// ── Component ──
|
||||
|
||||
class FolkCalendarView extends HTMLElement {
|
||||
|
|
@ -117,6 +121,7 @@ class FolkCalendarView extends HTMLElement {
|
|||
private error = "";
|
||||
private filteredSources = new Set<string>();
|
||||
private boundKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||
private _offlineUnsub: (() => void) | null = null;
|
||||
|
||||
// Spatio-temporal state
|
||||
private temporalGranularity = 4; // MONTH
|
||||
|
|
@ -150,11 +155,14 @@ class FolkCalendarView extends HTMLElement {
|
|||
this.boundKeyHandler = (e: KeyboardEvent) => this.handleKeydown(e);
|
||||
document.addEventListener("keydown", this.boundKeyHandler);
|
||||
if (this.space === "demo") { this.loadDemoData(); return; }
|
||||
this.subscribeOffline();
|
||||
this.loadMonth();
|
||||
this.render();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this._offlineUnsub?.();
|
||||
this._offlineUnsub = null;
|
||||
if (this.boundKeyHandler) {
|
||||
document.removeEventListener("keydown", this.boundKeyHandler);
|
||||
this.boundKeyHandler = null;
|
||||
|
|
@ -167,6 +175,55 @@ class FolkCalendarView extends HTMLElement {
|
|||
}
|
||||
}
|
||||
|
||||
private async subscribeOffline() {
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (!runtime?.isInitialized) return;
|
||||
|
||||
try {
|
||||
const docId = calendarDocId(this.space) as DocumentId;
|
||||
const doc = await runtime.subscribe(docId, calendarSchema);
|
||||
this.renderFromCalDoc(doc);
|
||||
|
||||
this._offlineUnsub = runtime.onChange(docId, (updated: any) => {
|
||||
this.renderFromCalDoc(updated);
|
||||
});
|
||||
} catch {
|
||||
// Runtime unavailable — REST fallback handles data loading
|
||||
}
|
||||
}
|
||||
|
||||
private renderFromCalDoc(doc: CalendarDoc) {
|
||||
if (!doc) return;
|
||||
|
||||
// Populate sources from Automerge doc
|
||||
if (doc.sources && Object.keys(doc.sources).length > 0) {
|
||||
this.sources = Object.values(doc.sources).map(s => ({
|
||||
id: s.id, name: s.name, source_type: s.sourceType,
|
||||
url: s.url, color: s.color, is_active: s.isActive,
|
||||
is_visible: s.isVisible, owner_id: s.ownerId,
|
||||
}));
|
||||
}
|
||||
|
||||
// Populate events from Automerge doc (supplement REST data)
|
||||
if (doc.events && Object.keys(doc.events).length > 0) {
|
||||
const docEvents = Object.values(doc.events).map(e => ({
|
||||
id: e.id, title: e.title, description: e.description,
|
||||
start_time: new Date(e.startTime).toISOString(),
|
||||
end_time: new Date(e.endTime).toISOString(),
|
||||
all_day: e.allDay, source_id: e.sourceId,
|
||||
source_name: e.sourceName, source_color: e.sourceColor,
|
||||
location_name: e.locationName,
|
||||
location_lat: e.locationLat, location_lng: e.locationLng,
|
||||
}));
|
||||
// Only use doc events if REST hasn't loaded yet
|
||||
if (this.events.length === 0 && docEvents.length > 0) {
|
||||
this.events = docEvents;
|
||||
}
|
||||
}
|
||||
|
||||
this.render();
|
||||
}
|
||||
|
||||
// ── Keyboard ──
|
||||
|
||||
private handleKeydown(e: KeyboardEvent) {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@
|
|||
* Shows catalog items, order creation flow, and order status tracking.
|
||||
*/
|
||||
|
||||
import { catalogSchema, catalogDocId, type CatalogDoc, orderSchema, type OrderDoc } from "../schemas";
|
||||
import type { DocumentId } from "../../../shared/local-first/document";
|
||||
|
||||
class FolkCartShop extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
private space = "default";
|
||||
|
|
@ -10,6 +13,7 @@ class FolkCartShop extends HTMLElement {
|
|||
private orders: any[] = [];
|
||||
private view: "catalog" | "orders" = "catalog";
|
||||
private loading = true;
|
||||
private _offlineUnsubs: (() => void)[] = [];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
|
@ -32,9 +36,67 @@ class FolkCartShop extends HTMLElement {
|
|||
}
|
||||
|
||||
this.render();
|
||||
this.subscribeOffline();
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
for (const unsub of this._offlineUnsubs) unsub();
|
||||
this._offlineUnsubs = [];
|
||||
}
|
||||
|
||||
private async subscribeOffline() {
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (!runtime?.isInitialized) return;
|
||||
|
||||
try {
|
||||
// Subscribe to catalog (single doc per space)
|
||||
const catDocId = catalogDocId(this.space) as DocumentId;
|
||||
const catDoc = await runtime.subscribe(catDocId, catalogSchema);
|
||||
if (catDoc?.items && Object.keys(catDoc.items).length > 0 && this.catalog.length === 0) {
|
||||
this.catalog = Object.values((catDoc as CatalogDoc).items).map(item => ({
|
||||
id: item.id, title: item.title, description: '',
|
||||
product_type: item.productType, status: item.status,
|
||||
tags: item.tags || [],
|
||||
created_at: new Date(item.createdAt).toISOString(),
|
||||
}));
|
||||
this.loading = false;
|
||||
this.render();
|
||||
}
|
||||
this._offlineUnsubs.push(runtime.onChange(catDocId, (doc: CatalogDoc) => {
|
||||
if (doc?.items) {
|
||||
this.catalog = Object.values(doc.items).map(item => ({
|
||||
id: item.id, title: item.title, description: '',
|
||||
product_type: item.productType, status: item.status,
|
||||
tags: item.tags || [],
|
||||
created_at: new Date(item.createdAt).toISOString(),
|
||||
}));
|
||||
this.render();
|
||||
}
|
||||
}));
|
||||
|
||||
// Subscribe to orders (multi-doc)
|
||||
const orderDocs = await runtime.subscribeModule('cart', 'orders', orderSchema);
|
||||
if (orderDocs.size > 0 && this.orders.length === 0) {
|
||||
const fromDocs: any[] = [];
|
||||
for (const [docId, doc] of orderDocs) {
|
||||
const d = doc as OrderDoc;
|
||||
if (!d?.order) continue;
|
||||
fromDocs.push({
|
||||
id: d.order.id, status: d.order.status,
|
||||
quantity: d.order.quantity, total_price: d.order.totalPrice,
|
||||
currency: d.order.currency,
|
||||
created_at: new Date(d.order.createdAt).toISOString(),
|
||||
});
|
||||
}
|
||||
if (fromDocs.length > 0) {
|
||||
this.orders = fromDocs;
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
} catch { /* runtime unavailable */ }
|
||||
}
|
||||
|
||||
private loadDemoData() {
|
||||
const now = Date.now();
|
||||
this.catalog = [
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@
|
|||
* space="slug" — shared space to browse (default: "default")
|
||||
*/
|
||||
|
||||
import { filesSchema, type FilesDoc } from "../schemas";
|
||||
import type { DocumentId } from "../../../shared/local-first/document";
|
||||
|
||||
class FolkFileBrowser extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
private space = "default";
|
||||
|
|
@ -12,6 +15,7 @@ class FolkFileBrowser extends HTMLElement {
|
|||
private cards: any[] = [];
|
||||
private tab: "files" | "cards" = "files";
|
||||
private loading = false;
|
||||
private _offlineUnsubs: (() => void)[] = [];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
|
@ -27,10 +31,47 @@ class FolkFileBrowser extends HTMLElement {
|
|||
}
|
||||
|
||||
this.render();
|
||||
this.subscribeOffline();
|
||||
this.loadFiles();
|
||||
this.loadCards();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
for (const unsub of this._offlineUnsubs) unsub();
|
||||
this._offlineUnsubs = [];
|
||||
}
|
||||
|
||||
private async subscribeOffline() {
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (!runtime?.isInitialized) return;
|
||||
|
||||
try {
|
||||
const docs = await runtime.subscribeModule('files', 'cards', filesSchema);
|
||||
for (const [docId, doc] of docs) {
|
||||
const d = doc as FilesDoc;
|
||||
if (!d) continue;
|
||||
|
||||
// Populate files metadata from Automerge if REST hasn't loaded yet
|
||||
if (d.files && Object.keys(d.files).length > 0 && this.files.length === 0) {
|
||||
this.files = Object.values(d.files).map(f => ({
|
||||
id: f.id, name: f.originalFilename, original_filename: f.originalFilename,
|
||||
title: f.title || f.originalFilename, size: f.fileSize, file_size: f.fileSize,
|
||||
mime_type: f.mimeType, created_at: new Date(f.createdAt).toISOString(),
|
||||
space: this.space,
|
||||
}));
|
||||
}
|
||||
|
||||
// Populate memory cards
|
||||
if (d.memoryCards && Object.keys(d.memoryCards).length > 0 && this.cards.length === 0) {
|
||||
this.cards = Object.values(d.memoryCards);
|
||||
}
|
||||
|
||||
this._offlineUnsubs.push(runtime.onChange(docId, () => this.render()));
|
||||
}
|
||||
if (this.files.length > 0 || this.cards.length > 0) this.render();
|
||||
} catch { /* runtime unavailable */ }
|
||||
}
|
||||
|
||||
private loadDemoData() {
|
||||
const now = Date.now();
|
||||
this.files = [
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ import { PORT_DEFS, deriveThresholds } from "../lib/types";
|
|||
import { computeSufficiencyState, computeSystemSufficiency, simulateTick } from "../lib/simulation";
|
||||
import { demoNodes, SPENDING_COLORS, OVERFLOW_COLORS } from "../lib/presets";
|
||||
import { mapFlowToNodes } from "../lib/map-flow";
|
||||
import { flowsSchema, flowsDocId, type FlowsDoc } from "../schemas";
|
||||
import type { DocumentId } from "../../../shared/local-first/document";
|
||||
|
||||
interface FlowSummary {
|
||||
id: string;
|
||||
|
|
@ -83,6 +85,7 @@ class FolkFlowsApp extends HTMLElement {
|
|||
|
||||
private loading = false;
|
||||
private error = "";
|
||||
private _offlineUnsub: (() => void) | null = null;
|
||||
|
||||
// Canvas state
|
||||
private canvasZoom = 1;
|
||||
|
|
@ -160,6 +163,50 @@ class FolkFlowsApp extends HTMLElement {
|
|||
this.view = "landing";
|
||||
this.loadFlows();
|
||||
}
|
||||
|
||||
// Subscribe to offline-first Automerge doc for flow associations
|
||||
if (!this.isDemo) this.subscribeOffline();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this._offlineUnsub?.();
|
||||
this._offlineUnsub = null;
|
||||
}
|
||||
|
||||
private async subscribeOffline() {
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (!runtime?.isInitialized) return;
|
||||
|
||||
try {
|
||||
const docId = flowsDocId(this.space) as DocumentId;
|
||||
const doc = await runtime.subscribe(docId, flowsSchema);
|
||||
|
||||
// Render cached flow associations immediately
|
||||
this.renderFlowsFromDoc(doc);
|
||||
|
||||
// Listen for remote changes
|
||||
this._offlineUnsub = runtime.onChange(docId, (updated: any) => {
|
||||
this.renderFlowsFromDoc(updated);
|
||||
});
|
||||
} catch {
|
||||
// Offline runtime unavailable — REST fallback already running
|
||||
}
|
||||
}
|
||||
|
||||
private renderFlowsFromDoc(doc: FlowsDoc) {
|
||||
if (!doc?.spaceFlows) return;
|
||||
const entries = Object.values(doc.spaceFlows);
|
||||
if (entries.length === 0 && this.flows.length > 0) return; // Don't clobber REST data with empty doc
|
||||
// Merge Automerge flow associations as summaries
|
||||
const fromDoc: FlowSummary[] = entries.map(sf => ({
|
||||
id: sf.flowId,
|
||||
name: sf.flowId,
|
||||
status: 'active',
|
||||
}));
|
||||
if (fromDoc.length > 0 && this.flows.length === 0) {
|
||||
this.flows = fromDoc;
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
private getApiBase(): string {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@
|
|||
* creating new instances.
|
||||
*/
|
||||
|
||||
import { forumSchema, FORUM_DOC_ID, type ForumDoc } from "../schemas";
|
||||
import type { DocumentId } from "../../../shared/local-first/document";
|
||||
|
||||
class FolkForumDashboard extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
private instances: any[] = [];
|
||||
|
|
@ -14,6 +17,7 @@ class FolkForumDashboard extends HTMLElement {
|
|||
private loading = false;
|
||||
private pollTimer: number | null = null;
|
||||
private space = "";
|
||||
private _offlineUnsub: (() => void) | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
|
@ -23,6 +27,7 @@ class FolkForumDashboard extends HTMLElement {
|
|||
connectedCallback() {
|
||||
this.space = this.getAttribute("space") || "";
|
||||
if (this.space === "demo") { this.loadDemoData(); return; }
|
||||
this.subscribeOffline();
|
||||
this.render();
|
||||
this.loadInstances();
|
||||
}
|
||||
|
|
@ -39,6 +44,41 @@ class FolkForumDashboard extends HTMLElement {
|
|||
|
||||
disconnectedCallback() {
|
||||
if (this.pollTimer) clearInterval(this.pollTimer);
|
||||
this._offlineUnsub?.();
|
||||
this._offlineUnsub = null;
|
||||
}
|
||||
|
||||
private async subscribeOffline() {
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (!runtime?.isInitialized) return;
|
||||
|
||||
try {
|
||||
// Forum uses a global doc (not space-scoped)
|
||||
const docId = FORUM_DOC_ID as DocumentId;
|
||||
const doc = await runtime.subscribe(docId, forumSchema);
|
||||
|
||||
if (doc?.instances && Object.keys(doc.instances).length > 0 && this.instances.length === 0) {
|
||||
this.instances = Object.values((doc as ForumDoc).instances).map(inst => ({
|
||||
id: inst.id, name: inst.name, domain: inst.domain,
|
||||
status: inst.status, region: inst.region, size: inst.size,
|
||||
adminEmail: inst.adminEmail, vpsIp: inst.vpsIp,
|
||||
sslProvisioned: inst.sslProvisioned,
|
||||
}));
|
||||
this.render();
|
||||
}
|
||||
|
||||
this._offlineUnsub = runtime.onChange(docId, (updated: ForumDoc) => {
|
||||
if (updated?.instances) {
|
||||
this.instances = Object.values(updated.instances).map(inst => ({
|
||||
id: inst.id, name: inst.name, domain: inst.domain,
|
||||
status: inst.status, region: inst.region, size: inst.size,
|
||||
adminEmail: inst.adminEmail, vpsIp: inst.vpsIp,
|
||||
sslProvisioned: inst.sslProvisioned,
|
||||
}));
|
||||
this.render();
|
||||
}
|
||||
});
|
||||
} catch { /* runtime unavailable */ }
|
||||
}
|
||||
|
||||
private getApiBase(): string {
|
||||
|
|
|
|||
|
|
@ -5,9 +5,13 @@
|
|||
* and approval workflow interface. Includes a help/guide popout.
|
||||
*/
|
||||
|
||||
import { mailboxSchema, type MailboxDoc } from "../schemas";
|
||||
import type { DocumentId } from "../../../shared/local-first/document";
|
||||
|
||||
class FolkInboxClient extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
private space = "demo";
|
||||
private _offlineUnsubs: (() => void)[] = [];
|
||||
private view: "mailboxes" | "threads" | "thread" | "approvals" = "mailboxes";
|
||||
private mailboxes: any[] = [];
|
||||
private threads: any[] = [];
|
||||
|
|
@ -73,9 +77,41 @@ class FolkInboxClient extends HTMLElement {
|
|||
connectedCallback() {
|
||||
this.space = this.getAttribute("space") || "demo";
|
||||
if (this.space === "demo") { this.loadDemoData(); return; }
|
||||
this.subscribeOffline();
|
||||
this.loadMailboxes();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
for (const unsub of this._offlineUnsubs) unsub();
|
||||
this._offlineUnsubs = [];
|
||||
}
|
||||
|
||||
private async subscribeOffline() {
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (!runtime?.isInitialized) return;
|
||||
|
||||
try {
|
||||
const docs = await runtime.subscribeModule('inbox', 'mailboxes', mailboxSchema);
|
||||
if (docs.size > 0 && this.mailboxes.length === 0) {
|
||||
const fromDocs: any[] = [];
|
||||
for (const [docId, doc] of docs) {
|
||||
const d = doc as MailboxDoc;
|
||||
if (!d?.mailbox) continue;
|
||||
fromDocs.push({
|
||||
slug: d.mailbox.slug, name: d.mailbox.name,
|
||||
email: d.mailbox.email, description: d.mailbox.description,
|
||||
thread_count: Object.keys(d.threads || {}).length,
|
||||
});
|
||||
this._offlineUnsubs.push(runtime.onChange(docId, () => {}));
|
||||
}
|
||||
if (fromDocs.length > 0) {
|
||||
this.mailboxes = fromDocs;
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
} catch { /* runtime unavailable */ }
|
||||
}
|
||||
|
||||
private loadDemoData() {
|
||||
this.mailboxes = [
|
||||
{ slug: "team", name: "Team Inbox", email: "team@rspace.online", description: "Shared workspace inbox for internal team communications", thread_count: 4, unread_count: 1 },
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@
|
|||
*/
|
||||
|
||||
import * as Automerge from '@automerge/automerge';
|
||||
import { notebookSchema } from '../schemas';
|
||||
import type { DocumentId } from '../../../shared/local-first/document';
|
||||
import { Editor } from '@tiptap/core';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Link from '@tiptap/extension-link';
|
||||
|
|
@ -104,13 +106,12 @@ class FolkNotesApp extends HTMLElement {
|
|||
private isRemoteUpdate = false;
|
||||
private editorUpdateTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Automerge sync state
|
||||
private ws: WebSocket | null = null;
|
||||
// Automerge sync state (via shared runtime)
|
||||
private doc: Automerge.Doc<NotebookDoc> | null = null;
|
||||
private syncState: Automerge.SyncState = Automerge.initSyncState();
|
||||
private subscribedDocId: string | null = null;
|
||||
private syncConnected = false;
|
||||
private pingInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private _offlineUnsub: (() => void) | null = null;
|
||||
private _offlineNotebookUnsubs: (() => void)[] = [];
|
||||
|
||||
// ── Demo data ──
|
||||
private demoNotebooks: (Notebook & { notes: Note[] })[] = [];
|
||||
|
|
@ -124,10 +125,48 @@ class FolkNotesApp extends HTMLElement {
|
|||
this.space = this.getAttribute("space") || "demo";
|
||||
this.setupShadow();
|
||||
if (this.space === "demo") { this.loadDemoData(); return; }
|
||||
this.connectSync();
|
||||
this.subscribeOfflineRuntime();
|
||||
this.loadNotebooks();
|
||||
}
|
||||
|
||||
private async subscribeOfflineRuntime() {
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (!runtime?.isInitialized) return;
|
||||
|
||||
try {
|
||||
// Discover all cached notebooks for this space
|
||||
const docs = await runtime.subscribeModule('notes', 'notebooks', notebookSchema);
|
||||
this.syncConnected = runtime.isOnline;
|
||||
|
||||
// Listen for connection state changes
|
||||
this._offlineUnsub = runtime.onStatusChange((status: string) => {
|
||||
this.syncConnected = status === 'online';
|
||||
});
|
||||
|
||||
// Populate notebook list from cached docs if REST hasn't loaded
|
||||
if (docs.size > 0 && this.notebooks.length === 0) {
|
||||
const fromDocs: Notebook[] = [];
|
||||
for (const [, doc] of docs) {
|
||||
const d = doc as NotebookDoc;
|
||||
if (!d?.notebook?.id) continue;
|
||||
fromDocs.push({
|
||||
id: d.notebook.id, title: d.notebook.title,
|
||||
description: d.notebook.description || '',
|
||||
cover_color: d.notebook.coverColor || '#3b82f6',
|
||||
note_count: String(Object.keys(d.items || {}).length),
|
||||
updated_at: d.notebook.updatedAt ? new Date(d.notebook.updatedAt).toISOString() : new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
if (fromDocs.length > 0) {
|
||||
this.notebooks = fromDocs;
|
||||
this.renderNav();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Runtime unavailable — REST fallback handles data
|
||||
}
|
||||
}
|
||||
|
||||
private setupShadow() {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = this.getStyles();
|
||||
|
|
@ -360,107 +399,51 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
|||
|
||||
disconnectedCallback() {
|
||||
this.destroyEditor();
|
||||
this.disconnectSync();
|
||||
this._offlineUnsub?.();
|
||||
this._offlineUnsub = null;
|
||||
for (const unsub of this._offlineNotebookUnsubs) unsub();
|
||||
this._offlineNotebookUnsubs = [];
|
||||
}
|
||||
|
||||
// ── WebSocket Sync ──
|
||||
// ── Sync (via shared runtime) ──
|
||||
|
||||
private connectSync() {
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const wsUrl = `${proto}//${location.host}/ws/${this.space}`;
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.syncConnected = true;
|
||||
this.pingInterval = setInterval(() => {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify({ type: "ping", timestamp: Date.now() }));
|
||||
}
|
||||
}, 30000);
|
||||
if (this.subscribedDocId && this.doc) {
|
||||
this.subscribeNotebook(this.subscribedDocId.split(":").pop()!);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onmessage = (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === "sync" && msg.docId === this.subscribedDocId) {
|
||||
this.handleSyncMessage(new Uint8Array(msg.data));
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.syncConnected = false;
|
||||
if (this.pingInterval) clearInterval(this.pingInterval);
|
||||
setTimeout(() => {
|
||||
if (this.isConnected) this.connectSync();
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {};
|
||||
}
|
||||
|
||||
private disconnectSync() {
|
||||
if (this.pingInterval) clearInterval(this.pingInterval);
|
||||
if (this.ws) {
|
||||
this.ws.onclose = null;
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
this.syncConnected = false;
|
||||
}
|
||||
|
||||
private handleSyncMessage(syncMsg: Uint8Array) {
|
||||
if (!this.doc) return;
|
||||
|
||||
const [newDoc, newSyncState] = Automerge.receiveSyncMessage(
|
||||
this.doc, this.syncState, syncMsg
|
||||
);
|
||||
this.doc = newDoc;
|
||||
this.syncState = newSyncState;
|
||||
|
||||
const [nextState, reply] = Automerge.generateSyncMessage(this.doc, this.syncState);
|
||||
this.syncState = nextState;
|
||||
if (reply && this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify({
|
||||
type: "sync", docId: this.subscribedDocId,
|
||||
data: Array.from(reply),
|
||||
}));
|
||||
}
|
||||
|
||||
this.renderFromDoc();
|
||||
}
|
||||
|
||||
private subscribeNotebook(notebookId: string) {
|
||||
private async subscribeNotebook(notebookId: string) {
|
||||
this.subscribedDocId = `${this.space}:notes:notebooks:${notebookId}`;
|
||||
this.doc = Automerge.init<NotebookDoc>();
|
||||
this.syncState = Automerge.initSyncState();
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||
if (runtime?.isInitialized) {
|
||||
try {
|
||||
const docId = this.subscribedDocId as DocumentId;
|
||||
const doc = await runtime.subscribe(docId, notebookSchema);
|
||||
this.doc = doc;
|
||||
this.renderFromDoc();
|
||||
|
||||
this.ws.send(JSON.stringify({ type: "subscribe", docIds: [this.subscribedDocId] }));
|
||||
|
||||
const [s, m] = Automerge.generateSyncMessage(this.doc, this.syncState);
|
||||
this.syncState = s;
|
||||
if (m) {
|
||||
this.ws.send(JSON.stringify({
|
||||
type: "sync", docId: this.subscribedDocId,
|
||||
data: Array.from(m),
|
||||
}));
|
||||
const unsub = runtime.onChange(docId, (updated: any) => {
|
||||
this.doc = updated;
|
||||
this.renderFromDoc();
|
||||
});
|
||||
this._offlineNotebookUnsubs.push(unsub);
|
||||
} catch {
|
||||
// Fallback: initialize empty doc for fresh notebook
|
||||
this.doc = Automerge.init<NotebookDoc>();
|
||||
}
|
||||
} else {
|
||||
// No runtime — initialize empty doc
|
||||
this.doc = Automerge.init<NotebookDoc>();
|
||||
}
|
||||
}
|
||||
|
||||
private unsubscribeNotebook() {
|
||||
if (this.subscribedDocId && this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify({ type: "unsubscribe", docIds: [this.subscribedDocId] }));
|
||||
if (this.subscribedDocId) {
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (runtime?.isInitialized) {
|
||||
runtime.unsubscribe(this.subscribedDocId as DocumentId);
|
||||
}
|
||||
}
|
||||
this.subscribedDocId = null;
|
||||
this.doc = null;
|
||||
this.syncState = Automerge.initSyncState();
|
||||
for (const unsub of this._offlineNotebookUnsubs) unsub();
|
||||
this._offlineNotebookUnsubs = [];
|
||||
}
|
||||
|
||||
/** Extract notebook + notes from Automerge doc into component state */
|
||||
|
|
@ -586,30 +569,36 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
|||
// ── Automerge mutations ──
|
||||
|
||||
private createNoteViaSync() {
|
||||
if (!this.doc || !this.selectedNotebook) return;
|
||||
if (!this.doc || !this.selectedNotebook || !this.subscribedDocId) return;
|
||||
|
||||
const noteId = crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
const notebookId = this.selectedNotebook.id;
|
||||
|
||||
this.doc = Automerge.change(this.doc, "Create note", (d: NotebookDoc) => {
|
||||
if (!d.items) (d as any).items = {};
|
||||
d.items[noteId] = {
|
||||
id: noteId,
|
||||
notebookId: this.selectedNotebook!.id,
|
||||
title: "Untitled Note",
|
||||
content: "",
|
||||
contentPlain: "",
|
||||
contentFormat: "tiptap-json",
|
||||
type: "NOTE",
|
||||
tags: [],
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
});
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (runtime?.isInitialized) {
|
||||
runtime.change(this.subscribedDocId as DocumentId, "Create note", (d: NotebookDoc) => {
|
||||
if (!d.items) (d as any).items = {};
|
||||
d.items[noteId] = {
|
||||
id: noteId, notebookId, title: "Untitled Note",
|
||||
content: "", contentPlain: "", contentFormat: "tiptap-json",
|
||||
type: "NOTE", tags: [], isPinned: false, sortOrder: 0,
|
||||
createdAt: now, updatedAt: now,
|
||||
};
|
||||
});
|
||||
this.doc = runtime.get(this.subscribedDocId as DocumentId);
|
||||
} else {
|
||||
this.doc = Automerge.change(this.doc, "Create note", (d: NotebookDoc) => {
|
||||
if (!d.items) (d as any).items = {};
|
||||
d.items[noteId] = {
|
||||
id: noteId, notebookId, title: "Untitled Note",
|
||||
content: "", contentPlain: "", contentFormat: "tiptap-json",
|
||||
type: "NOTE", tags: [], isPinned: false, sortOrder: 0,
|
||||
createdAt: now, updatedAt: now,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
this.sendSyncAfterChange();
|
||||
this.renderFromDoc();
|
||||
|
||||
// Open the new note
|
||||
|
|
@ -626,26 +615,20 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
|||
}
|
||||
|
||||
private updateNoteField(noteId: string, field: string, value: string) {
|
||||
if (!this.doc || !this.doc.items?.[noteId]) return;
|
||||
if (!this.doc || !this.doc.items?.[noteId] || !this.subscribedDocId) return;
|
||||
|
||||
this.doc = Automerge.change(this.doc, `Update ${field}`, (d: NotebookDoc) => {
|
||||
(d.items[noteId] as any)[field] = value;
|
||||
d.items[noteId].updatedAt = Date.now();
|
||||
});
|
||||
|
||||
this.sendSyncAfterChange();
|
||||
}
|
||||
|
||||
private sendSyncAfterChange() {
|
||||
if (!this.doc || !this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
const [newState, msg] = Automerge.generateSyncMessage(this.doc, this.syncState);
|
||||
this.syncState = newState;
|
||||
if (msg) {
|
||||
this.ws.send(JSON.stringify({
|
||||
type: "sync", docId: this.subscribedDocId,
|
||||
data: Array.from(msg),
|
||||
}));
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (runtime?.isInitialized) {
|
||||
runtime.change(this.subscribedDocId as DocumentId, `Update ${field}`, (d: NotebookDoc) => {
|
||||
(d.items[noteId] as any)[field] = value;
|
||||
d.items[noteId].updatedAt = Date.now();
|
||||
});
|
||||
this.doc = runtime.get(this.subscribedDocId as DocumentId);
|
||||
} else {
|
||||
this.doc = Automerge.change(this.doc, `Update ${field}`, (d: NotebookDoc) => {
|
||||
(d.items[noteId] as any)[field] = value;
|
||||
d.items[noteId].updatedAt = Date.now();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,9 +2,12 @@
|
|||
* <folk-schedule-app> — schedule management UI.
|
||||
*
|
||||
* Job list with create/edit forms, execution log viewer,
|
||||
* and manual run triggers. REST-based (no Automerge client sync).
|
||||
* and manual run triggers. REST-based with offline-first Automerge sync.
|
||||
*/
|
||||
|
||||
import { scheduleSchema, scheduleDocId, type ScheduleDoc } from "../schemas";
|
||||
import type { DocumentId } from "../../../shared/local-first/document";
|
||||
|
||||
interface JobData {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
@ -83,6 +86,7 @@ class FolkScheduleApp extends HTMLElement {
|
|||
private editingReminder: ReminderData | null = null;
|
||||
private loading = false;
|
||||
private runningJobId: string | null = null;
|
||||
private _offlineUnsub: (() => void) | null = null;
|
||||
|
||||
// Reminder form state
|
||||
private rFormTitle = "";
|
||||
|
|
@ -109,9 +113,89 @@ class FolkScheduleApp extends HTMLElement {
|
|||
|
||||
connectedCallback() {
|
||||
this.space = this.getAttribute("space") || "demo";
|
||||
this.subscribeOffline();
|
||||
this.loadJobs();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this._offlineUnsub) {
|
||||
this._offlineUnsub();
|
||||
this._offlineUnsub = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async subscribeOffline() {
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (!runtime?.isInitialized) return;
|
||||
|
||||
try {
|
||||
const docId = scheduleDocId(this.space) as DocumentId;
|
||||
const doc = await runtime.subscribe(docId, scheduleSchema);
|
||||
if (doc) this.renderFromDoc(doc as ScheduleDoc);
|
||||
|
||||
this._offlineUnsub = runtime.onChange(docId, (doc: ScheduleDoc) => {
|
||||
if (doc) this.renderFromDoc(doc);
|
||||
});
|
||||
} catch { /* runtime unavailable */ }
|
||||
}
|
||||
|
||||
private renderFromDoc(doc: ScheduleDoc) {
|
||||
if (doc.jobs && Object.keys(doc.jobs).length > 0) {
|
||||
this.jobs = Object.values(doc.jobs).map((j) => ({
|
||||
id: j.id,
|
||||
name: j.name,
|
||||
description: j.description,
|
||||
enabled: j.enabled,
|
||||
cronExpression: j.cronExpression,
|
||||
timezone: j.timezone,
|
||||
actionType: j.actionType,
|
||||
actionConfig: j.actionConfig as Record<string, unknown>,
|
||||
lastRunAt: j.lastRunAt,
|
||||
lastRunStatus: j.lastRunStatus,
|
||||
lastRunMessage: j.lastRunMessage,
|
||||
nextRunAt: j.nextRunAt,
|
||||
runCount: j.runCount,
|
||||
createdBy: j.createdBy,
|
||||
createdAt: j.createdAt,
|
||||
updatedAt: j.updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
if (doc.reminders && Object.keys(doc.reminders).length > 0) {
|
||||
this.reminders = Object.values(doc.reminders).map((r) => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
description: r.description,
|
||||
remindAt: r.remindAt,
|
||||
allDay: r.allDay,
|
||||
timezone: r.timezone,
|
||||
notifyEmail: r.notifyEmail,
|
||||
notified: r.notified,
|
||||
completed: r.completed,
|
||||
sourceModule: r.sourceModule,
|
||||
sourceLabel: r.sourceLabel,
|
||||
sourceColor: r.sourceColor,
|
||||
cronExpression: r.cronExpression,
|
||||
createdAt: r.createdAt,
|
||||
updatedAt: r.updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
if (doc.log && doc.log.length > 0) {
|
||||
this.log = doc.log.map((e) => ({
|
||||
id: e.id,
|
||||
jobId: e.jobId,
|
||||
status: e.status,
|
||||
message: e.message,
|
||||
durationMs: e.durationMs,
|
||||
timestamp: e.timestamp,
|
||||
}));
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
this.render();
|
||||
}
|
||||
|
||||
private getApiBase(): string {
|
||||
const path = window.location.pathname;
|
||||
const match = path.match(/^(\/[^/]+)?\/rschedule/);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@
|
|||
* Three.js and GaussianSplats3D are loaded via CDN importmap (not bundled).
|
||||
*/
|
||||
|
||||
import { splatScenesSchema, splatScenesDocId, type SplatScenesDoc } from "../schemas";
|
||||
import type { DocumentId } from "../../../shared/local-first/document";
|
||||
|
||||
interface SplatItem {
|
||||
id: string;
|
||||
slug: string;
|
||||
|
|
@ -30,6 +33,7 @@ export class FolkSplatViewer extends HTMLElement {
|
|||
private _splatDesc = "";
|
||||
private _viewer: any = null;
|
||||
private _uploadMode: "splat" | "media" = "splat";
|
||||
private _offlineUnsub: (() => void) | null = null;
|
||||
|
||||
static get observedAttributes() {
|
||||
return ["mode", "splat-url", "splat-title", "splat-desc", "space-slug"];
|
||||
|
|
@ -54,11 +58,46 @@ export class FolkSplatViewer extends HTMLElement {
|
|||
if (this._mode === "viewer") {
|
||||
this.renderViewer();
|
||||
} else {
|
||||
if (this._spaceSlug === "demo") this.loadDemoData();
|
||||
if (this._spaceSlug === "demo") {
|
||||
this.loadDemoData();
|
||||
} else {
|
||||
this.subscribeOffline();
|
||||
}
|
||||
this.renderGallery();
|
||||
}
|
||||
}
|
||||
|
||||
private async subscribeOffline() {
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (!runtime?.isInitialized) return;
|
||||
|
||||
try {
|
||||
const docId = splatScenesDocId(this._spaceSlug) as DocumentId;
|
||||
const doc = await runtime.subscribe(docId, splatScenesSchema);
|
||||
this.renderFromDoc(doc);
|
||||
|
||||
this._offlineUnsub = runtime.onChange(docId, (updated: any) => {
|
||||
this.renderFromDoc(updated);
|
||||
});
|
||||
} catch {
|
||||
// Runtime unavailable — server-side hydration handles data
|
||||
}
|
||||
}
|
||||
|
||||
private renderFromDoc(doc: SplatScenesDoc) {
|
||||
if (!doc?.items || Object.keys(doc.items).length === 0) return;
|
||||
if (this._splats.length > 0) return; // Don't clobber server-hydrated data
|
||||
this._splats = Object.values(doc.items).map(s => ({
|
||||
id: s.id, slug: s.slug, title: s.title, description: s.description,
|
||||
file_format: s.fileFormat, file_size_bytes: s.fileSizeBytes,
|
||||
view_count: s.viewCount, contributor_name: s.contributorName ?? undefined,
|
||||
processing_status: s.processingStatus ?? undefined,
|
||||
source_file_count: s.sourceFileCount,
|
||||
created_at: new Date(s.createdAt).toISOString(),
|
||||
}));
|
||||
if (this._mode === "gallery") this.renderGallery();
|
||||
}
|
||||
|
||||
private loadDemoData() {
|
||||
this._splats = [
|
||||
{ id: "s1", slug: "matterhorn-scan", title: "Matterhorn Summit", description: "Photogrammetry capture of the Matterhorn peak from drone footage, 42 source images.", file_format: "splat", file_size_bytes: 18_874_368, view_count: 284, contributor_name: "Alpine Explorer Team", processing_status: "ready", created_at: "2026-02-10" },
|
||||
|
|
@ -71,6 +110,8 @@ export class FolkSplatViewer extends HTMLElement {
|
|||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this._offlineUnsub?.();
|
||||
this._offlineUnsub = null;
|
||||
if (this._viewer) {
|
||||
try { this._viewer.dispose(); } catch {}
|
||||
this._viewer = null;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@
|
|||
* Demo: 4 trips with varied statuses and rich destination chains.
|
||||
*/
|
||||
|
||||
import { tripSchema, type TripDoc } from "../schemas";
|
||||
import type { DocumentId } from "../../../shared/local-first/document";
|
||||
|
||||
class FolkTripsPlanner extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
private space = "";
|
||||
|
|
@ -14,6 +17,7 @@ class FolkTripsPlanner extends HTMLElement {
|
|||
private trip: any = null;
|
||||
private tab: "overview" | "destinations" | "itinerary" | "bookings" | "expenses" | "packing" = "overview";
|
||||
private error = "";
|
||||
private _offlineUnsubs: (() => void)[] = [];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
|
@ -23,10 +27,43 @@ class FolkTripsPlanner extends HTMLElement {
|
|||
connectedCallback() {
|
||||
this.space = this.getAttribute("space") || "demo";
|
||||
if (this.space === "demo") { this.loadDemoData(); return; }
|
||||
this.subscribeOffline();
|
||||
this.loadTrips();
|
||||
this.render();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
for (const unsub of this._offlineUnsubs) unsub();
|
||||
this._offlineUnsubs = [];
|
||||
}
|
||||
|
||||
private async subscribeOffline() {
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (!runtime?.isInitialized) return;
|
||||
|
||||
try {
|
||||
const docs = await runtime.subscribeModule('trips', 'trips', tripSchema);
|
||||
if (docs.size > 0 && this.trips.length === 0) {
|
||||
const fromDocs: any[] = [];
|
||||
for (const [docId, doc] of docs) {
|
||||
const d = doc as TripDoc;
|
||||
if (!d?.trip) continue;
|
||||
fromDocs.push({
|
||||
id: d.trip.id, title: d.trip.title, status: d.trip.status,
|
||||
start_date: d.trip.startDate, end_date: d.trip.endDate,
|
||||
budget_total: d.trip.budgetTotal, description: d.trip.description,
|
||||
destination_count: Object.keys(d.destinations || {}).length,
|
||||
});
|
||||
this._offlineUnsubs.push(runtime.onChange(docId, () => {}));
|
||||
}
|
||||
if (fromDocs.length > 0) {
|
||||
this.trips = fromDocs;
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
} catch { /* runtime unavailable */ }
|
||||
}
|
||||
|
||||
private loadDemoData() {
|
||||
this.trips = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@
|
|||
* Browse spaces, create/view proposals, cast votes (ranking + final).
|
||||
*/
|
||||
|
||||
import { proposalSchema, type ProposalDoc } from "../schemas";
|
||||
import type { DocumentId } from "../../../shared/local-first/document";
|
||||
|
||||
interface VoteSpace {
|
||||
slug: string;
|
||||
name: string;
|
||||
|
|
@ -40,6 +43,7 @@ class FolkVoteDashboard extends HTMLElement {
|
|||
private selectedProposal: Proposal | null = null;
|
||||
private loading = false;
|
||||
private error = "";
|
||||
private _offlineUnsubs: (() => void)[] = [];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
|
@ -49,9 +53,46 @@ class FolkVoteDashboard extends HTMLElement {
|
|||
connectedCallback() {
|
||||
this.space = this.getAttribute("space") || "demo";
|
||||
if (this.space === "demo") { this.loadDemoData(); return; }
|
||||
this.subscribeOffline();
|
||||
this.loadSpaces();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
for (const unsub of this._offlineUnsubs) unsub();
|
||||
this._offlineUnsubs = [];
|
||||
}
|
||||
|
||||
private async subscribeOffline() {
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (!runtime?.isInitialized) return;
|
||||
|
||||
try {
|
||||
const docs = await runtime.subscribeModule('vote', 'proposals', proposalSchema);
|
||||
if (docs.size > 0 && this.proposals.length === 0) {
|
||||
const fromDocs: Proposal[] = [];
|
||||
for (const [docId, doc] of docs) {
|
||||
const d = doc as ProposalDoc;
|
||||
if (!d?.proposal) continue;
|
||||
fromDocs.push({
|
||||
id: d.proposal.id, title: d.proposal.title,
|
||||
description: d.proposal.description, status: d.proposal.status,
|
||||
score: d.proposal.score, vote_count: String(Object.keys(d.votes || {}).length),
|
||||
final_yes: d.proposal.finalYes, final_no: d.proposal.finalNo,
|
||||
final_abstain: d.proposal.finalAbstain,
|
||||
created_at: new Date(d.proposal.createdAt).toISOString(),
|
||||
voting_ends_at: d.proposal.votingEndsAt ? new Date(d.proposal.votingEndsAt).toISOString() : null,
|
||||
});
|
||||
this._offlineUnsubs.push(runtime.onChange(docId, () => {}));
|
||||
}
|
||||
if (fromDocs.length > 0) {
|
||||
this.proposals = fromDocs;
|
||||
this.view = "proposals";
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
} catch { /* runtime unavailable */ }
|
||||
}
|
||||
|
||||
private loadDemoData() {
|
||||
this.spaces = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@
|
|||
* Supports task creation, status changes, and priority labels.
|
||||
*/
|
||||
|
||||
import { boardSchema, type BoardDoc } from "../schemas";
|
||||
import type { DocumentId } from "../../../shared/local-first/document";
|
||||
|
||||
class FolkWorkBoard extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
private space = "";
|
||||
|
|
@ -20,6 +23,7 @@ class FolkWorkBoard extends HTMLElement {
|
|||
private editingTaskId: string | null = null;
|
||||
private showCreateForm = false;
|
||||
private priorities = ["LOW", "MEDIUM", "HIGH", "URGENT"];
|
||||
private _offlineUnsubs: (() => void)[] = [];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
|
@ -29,10 +33,55 @@ class FolkWorkBoard extends HTMLElement {
|
|||
connectedCallback() {
|
||||
this.space = this.getAttribute("space") || "demo";
|
||||
if (this.space === "demo") { this.loadDemoData(); return; }
|
||||
this.subscribeOffline();
|
||||
this.loadWorkspaces();
|
||||
this.render();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
for (const unsub of this._offlineUnsubs) unsub();
|
||||
this._offlineUnsubs = [];
|
||||
}
|
||||
|
||||
private async subscribeOffline() {
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (!runtime?.isInitialized) return;
|
||||
|
||||
try {
|
||||
const docs = await runtime.subscribeModule('work', 'boards', boardSchema);
|
||||
// Build workspace list from cached boards
|
||||
if (docs.size > 0 && this.workspaces.length === 0) {
|
||||
const boards: any[] = [];
|
||||
for (const [docId, doc] of docs) {
|
||||
const d = doc as BoardDoc;
|
||||
if (!d?.board) continue;
|
||||
boards.push({ slug: d.board.slug, name: d.board.name, icon: null, task_count: Object.keys(d.tasks || {}).length });
|
||||
this._offlineUnsubs.push(runtime.onChange(docId, () => this.refreshFromDocs()));
|
||||
}
|
||||
if (boards.length > 0) {
|
||||
this.workspaces = boards;
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
} catch { /* runtime unavailable */ }
|
||||
}
|
||||
|
||||
private refreshFromDocs() {
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (!runtime || !this.workspaceSlug) return;
|
||||
// Reload tasks for current board from runtime
|
||||
const docId = runtime.makeDocId('work', 'boards', this.workspaceSlug);
|
||||
const doc = runtime.get(docId) as BoardDoc | undefined;
|
||||
if (doc?.tasks && Object.keys(doc.tasks).length > 0) {
|
||||
this.tasks = Object.values(doc.tasks).map(t => ({
|
||||
id: t.id, title: t.title, description: t.description,
|
||||
status: t.status, priority: t.priority, labels: t.labels,
|
||||
assignee: t.assigneeId,
|
||||
}));
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
private loadDemoData() {
|
||||
this.isDemo = true;
|
||||
this.workspaces = [{ slug: "rspace-dev", name: "rSpace Development", icon: "\u{1F680}", task_count: 11, member_count: 2 }];
|
||||
|
|
|
|||
|
|
@ -62,6 +62,19 @@ interface RelayRestoreMessage {
|
|||
data: number[];
|
||||
}
|
||||
|
||||
interface DocListRequestMessage {
|
||||
type: 'doc-list-request';
|
||||
prefix: string;
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
interface DocListResponseMessage {
|
||||
type: 'doc-list-response';
|
||||
prefix: string;
|
||||
docIds: string[];
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
type WireMessage =
|
||||
| SyncMessage
|
||||
| SubscribeMessage
|
||||
|
|
@ -69,6 +82,8 @@ type WireMessage =
|
|||
| AwarenessMessage
|
||||
| RelayBackupMessage
|
||||
| RelayRestoreMessage
|
||||
| DocListRequestMessage
|
||||
| DocListResponseMessage
|
||||
| { type: 'ping' }
|
||||
| { type: 'pong' };
|
||||
|
||||
|
|
@ -199,6 +214,9 @@ export class SyncServer {
|
|||
case 'relay-backup':
|
||||
this.#handleRelayBackup(peer, msg as RelayBackupMessage);
|
||||
break;
|
||||
case 'doc-list-request':
|
||||
this.#handleDocListRequest(peer, msg as DocListRequestMessage);
|
||||
break;
|
||||
case 'ping':
|
||||
this.#sendToPeer(peer, { type: 'pong' });
|
||||
break;
|
||||
|
|
@ -400,6 +418,16 @@ export class SyncServer {
|
|||
}
|
||||
}
|
||||
|
||||
#handleDocListRequest(peer: Peer, msg: DocListRequestMessage): void {
|
||||
const docIds = this.getDocIds().filter(id => id.startsWith(msg.prefix));
|
||||
this.#sendToPeer(peer, {
|
||||
type: 'doc-list-response',
|
||||
prefix: msg.prefix,
|
||||
docIds,
|
||||
requestId: msg.requestId,
|
||||
});
|
||||
}
|
||||
|
||||
#sendSyncToPeer(peer: Peer, docId: string): void {
|
||||
const doc = this.#docs.get(docId);
|
||||
if (!doc) return;
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ export function renderShell(opts: ShellOptions): string {
|
|||
<style>${WELCOME_CSS}</style>
|
||||
<style>${ACCESS_GATE_CSS}</style>
|
||||
</head>
|
||||
<body data-space-visibility="${escapeAttr(spaceVisibility)}">
|
||||
<body data-space-visibility="${escapeAttr(spaceVisibility)}" data-space-slug="${escapeAttr(spaceSlug)}">
|
||||
<header class="rstack-header">
|
||||
<div class="rstack-header__left">
|
||||
<a href="/" style="display:flex;align-items:center;margin-right:4px"><img src="/favicon.png" alt="rSpace" class="rstack-header__logo"></a>
|
||||
|
|
@ -112,6 +112,7 @@ export function renderShell(opts: ShellOptions): string {
|
|||
</div>
|
||||
<div class="rstack-header__right">
|
||||
<a class="rstack-header__demo-btn" ${spaceSlug === "demo" ? 'data-hide' : ''} href="${shellDemoUrl}">Try Demo</a>
|
||||
<rstack-offline-indicator></rstack-offline-indicator>
|
||||
<rstack-notification-bell></rstack-notification-bell>
|
||||
<rstack-identity></rstack-identity>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
/**
|
||||
* <rstack-offline-indicator> — shows connection status in the shell header.
|
||||
*
|
||||
* States:
|
||||
* - online: hidden (no visual noise)
|
||||
* - syncing: brief spinner (shown during init)
|
||||
* - offline: orange dot + "Offline"
|
||||
* - error: red dot + tooltip with error info
|
||||
*/
|
||||
|
||||
import type { RuntimeStatus } from '../local-first/runtime';
|
||||
|
||||
export class RStackOfflineIndicator extends HTMLElement {
|
||||
#shadow: ShadowRoot;
|
||||
#status: RuntimeStatus = 'idle';
|
||||
#unsub: (() => void) | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.#shadow = this.attachShadow({ mode: 'open' });
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.#render();
|
||||
|
||||
// Connect to runtime when available
|
||||
const tryConnect = () => {
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (runtime) {
|
||||
this.#status = runtime.status;
|
||||
this.#render();
|
||||
this.#unsub = runtime.onStatusChange((s: RuntimeStatus) => {
|
||||
this.#status = s;
|
||||
this.#render();
|
||||
});
|
||||
} else {
|
||||
// Runtime not ready yet, try again shortly
|
||||
setTimeout(tryConnect, 500);
|
||||
}
|
||||
};
|
||||
tryConnect();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.#unsub?.();
|
||||
this.#unsub = null;
|
||||
}
|
||||
|
||||
#render() {
|
||||
const hidden = this.#status === 'online' || this.#status === 'idle';
|
||||
const isOffline = this.#status === 'offline';
|
||||
const isError = this.#status === 'error';
|
||||
const isSyncing = this.#status === 'initializing';
|
||||
|
||||
const dotColor = isError ? '#ef4444' : isOffline ? '#f59e0b' : '#3b82f6';
|
||||
const label = isError ? 'Sync Error' : isOffline ? 'Offline' : isSyncing ? 'Syncing' : '';
|
||||
const tooltip = isError
|
||||
? 'Unable to connect to sync server. Changes saved locally.'
|
||||
: isOffline
|
||||
? 'Working offline. Changes will sync when reconnected.'
|
||||
: isSyncing
|
||||
? 'Connecting to sync server...'
|
||||
: '';
|
||||
|
||||
this.#shadow.innerHTML = `
|
||||
<style>
|
||||
:host { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.indicator {
|
||||
display: ${hidden ? 'none' : 'inline-flex'};
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 12px;
|
||||
background: var(--rs-bg-secondary, rgba(255,255,255,0.06));
|
||||
font-size: 11px;
|
||||
color: var(--rs-text-secondary, #999);
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
.dot {
|
||||
width: 7px; height: 7px;
|
||||
border-radius: 50%;
|
||||
background: ${dotColor};
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
.dot.syncing {
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
background: #3b82f6;
|
||||
}
|
||||
</style>
|
||||
<div class="indicator" title="${tooltip}">
|
||||
<span class="dot${isSyncing ? ' syncing' : ''}"></span>
|
||||
<span>${label}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
static define() {
|
||||
if (!customElements.get('rstack-offline-indicator')) {
|
||||
customElements.define('rstack-offline-indicator', RStackOfflineIndicator);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -43,6 +43,23 @@ export {
|
|||
type WireMessage,
|
||||
} from './sync';
|
||||
|
||||
// Runtime (singleton coordinator for offline-first)
|
||||
export {
|
||||
RSpaceOfflineRuntime,
|
||||
type RuntimeStatus,
|
||||
type StatusCallback,
|
||||
} from './runtime';
|
||||
|
||||
// Storage Quota
|
||||
export {
|
||||
getStorageInfo,
|
||||
requestPersistentStorage,
|
||||
evictStaleDocs,
|
||||
isQuotaWarning,
|
||||
QUOTA_WARNING_PERCENT,
|
||||
type StorageInfo,
|
||||
} from './storage-quota';
|
||||
|
||||
// Backup
|
||||
export {
|
||||
BackupSyncManager,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,272 @@
|
|||
/**
|
||||
* Offline Runtime — singleton coordinator for all modules.
|
||||
*
|
||||
* Composes DocumentManager, EncryptedDocStore, and DocSyncManager into a
|
||||
* single entry point that web components use via `window.__rspaceOfflineRuntime`.
|
||||
*
|
||||
* Lifecycle:
|
||||
* 1. Shell creates runtime with space slug
|
||||
* 2. runtime.init() opens IndexedDB, connects WebSocket
|
||||
* 3. Components call runtime.subscribe(docId, schema) to get a live doc
|
||||
* 4. Components call runtime.change(docId, msg, fn) for mutations
|
||||
* 5. Shell calls runtime.flush() on beforeunload
|
||||
*/
|
||||
|
||||
import * as Automerge from '@automerge/automerge';
|
||||
import {
|
||||
type DocumentId,
|
||||
type DocSchema,
|
||||
makeDocumentId,
|
||||
DocumentManager,
|
||||
} from './document';
|
||||
import { EncryptedDocStore } from './storage';
|
||||
import { DocSyncManager } from './sync';
|
||||
import { DocCrypto } from './crypto';
|
||||
import {
|
||||
getStorageInfo,
|
||||
evictStaleDocs,
|
||||
requestPersistentStorage,
|
||||
QUOTA_WARNING_PERCENT,
|
||||
} from './storage-quota';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
export type RuntimeStatus = 'idle' | 'initializing' | 'online' | 'offline' | 'error';
|
||||
|
||||
export type StatusCallback = (status: RuntimeStatus) => void;
|
||||
|
||||
// ============================================================================
|
||||
// RSpaceOfflineRuntime
|
||||
// ============================================================================
|
||||
|
||||
export class RSpaceOfflineRuntime {
|
||||
#documents: DocumentManager;
|
||||
#store: EncryptedDocStore;
|
||||
#sync: DocSyncManager;
|
||||
#crypto: DocCrypto;
|
||||
#space: string;
|
||||
#status: RuntimeStatus = 'idle';
|
||||
#statusListeners = new Set<StatusCallback>();
|
||||
#initialized = false;
|
||||
|
||||
constructor(space: string) {
|
||||
this.#space = space;
|
||||
this.#crypto = new DocCrypto();
|
||||
this.#documents = new DocumentManager();
|
||||
this.#store = new EncryptedDocStore(space);
|
||||
this.#sync = new DocSyncManager({
|
||||
documents: this.#documents,
|
||||
store: this.#store,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Getters ──
|
||||
|
||||
get space(): string { return this.#space; }
|
||||
get isInitialized(): boolean { return this.#initialized; }
|
||||
get isOnline(): boolean { return this.#sync.isConnected; }
|
||||
get status(): RuntimeStatus { return this.#status; }
|
||||
|
||||
// ── Lifecycle ──
|
||||
|
||||
/**
|
||||
* Open IndexedDB and connect to the sync server.
|
||||
* Safe to call multiple times — no-ops if already initialized.
|
||||
*/
|
||||
async init(): Promise<void> {
|
||||
if (this.#initialized) return;
|
||||
this.#setStatus('initializing');
|
||||
|
||||
try {
|
||||
// 1. Open IndexedDB
|
||||
await this.#store.open();
|
||||
|
||||
// 2. Connect WebSocket (non-blocking — works offline)
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${proto}//${location.host}/ws/${this.#space}`;
|
||||
|
||||
this.#sync.onConnect(() => this.#setStatus('online'));
|
||||
this.#sync.onDisconnect(() => this.#setStatus('offline'));
|
||||
|
||||
try {
|
||||
await this.#sync.connect(wsUrl, this.#space);
|
||||
this.#setStatus('online');
|
||||
} catch {
|
||||
// WebSocket failed — still usable offline
|
||||
this.#setStatus('offline');
|
||||
}
|
||||
|
||||
this.#initialized = true;
|
||||
|
||||
// 3. Storage housekeeping (non-blocking)
|
||||
this.#runStorageHousekeeping();
|
||||
} catch (e) {
|
||||
this.#setStatus('error');
|
||||
console.error('[OfflineRuntime] Init failed:', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a document. Loads from IndexedDB (instant), syncs via WebSocket.
|
||||
* Returns the current Automerge doc.
|
||||
*/
|
||||
async subscribe<T extends Record<string, any>>(
|
||||
docId: DocumentId,
|
||||
schema: DocSchema<T>,
|
||||
): Promise<Automerge.Doc<T>> {
|
||||
// Register schema for future use
|
||||
this.#documents.registerSchema(schema);
|
||||
|
||||
// Try loading from IndexedDB first (instant cache hit)
|
||||
const cached = await this.#store.load(docId);
|
||||
const doc = this.#documents.open(docId, schema, cached ?? undefined);
|
||||
|
||||
// Subscribe for sync (sends subscribe + initial sync to server)
|
||||
await this.#sync.subscribe([docId]);
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from a document. Persists current state, stops syncing.
|
||||
*/
|
||||
unsubscribe(docId: DocumentId): void {
|
||||
// Save before unsubscribing
|
||||
const binary = this.#documents.save(docId);
|
||||
if (binary) {
|
||||
const meta = this.#documents.getMeta(docId);
|
||||
this.#store.save(docId, binary, meta ? {
|
||||
module: meta.module,
|
||||
collection: meta.collection,
|
||||
version: meta.version,
|
||||
} : undefined);
|
||||
}
|
||||
|
||||
this.#sync.unsubscribe([docId]);
|
||||
this.#documents.close(docId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutate a document. Persists to IndexedDB + syncs to server.
|
||||
*/
|
||||
change<T>(docId: DocumentId, message: string, fn: (doc: T) => void): void {
|
||||
this.#sync.change<T>(docId, message, fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current state of a document.
|
||||
*/
|
||||
get<T>(docId: DocumentId): Automerge.Doc<T> | undefined {
|
||||
return this.#documents.get<T>(docId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for changes (local or remote) on a document.
|
||||
* Returns an unsubscribe function.
|
||||
*/
|
||||
onChange<T>(docId: DocumentId, cb: (doc: Automerge.Doc<T>) => void): () => void {
|
||||
// Listen to both DocumentManager (local changes) and DocSyncManager (remote)
|
||||
const unsub1 = this.#documents.onChange<T>(docId, cb);
|
||||
const unsub2 = this.#sync.onChange(docId, cb as any);
|
||||
return () => { unsub1(); unsub2(); };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a DocumentId for a module using the current space.
|
||||
*/
|
||||
makeDocId(module: string, collection: string, itemId?: string): DocumentId {
|
||||
return makeDocumentId(this.#space, module, collection, itemId);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all cached doc IDs for a module/collection from IndexedDB.
|
||||
*/
|
||||
async listByModule(module: string, collection?: string): Promise<DocumentId[]> {
|
||||
return this.#store.listByModule(module, collection);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to all documents for a multi-doc module.
|
||||
* Discovers doc IDs from both IndexedDB cache and the server,
|
||||
* then subscribes to each. Returns all discovered docs.
|
||||
*/
|
||||
async subscribeModule<T extends Record<string, any>>(
|
||||
module: string,
|
||||
collection: string,
|
||||
schema: DocSchema<T>,
|
||||
): Promise<Map<DocumentId, Automerge.Doc<T>>> {
|
||||
this.#documents.registerSchema(schema);
|
||||
|
||||
const prefix = `${this.#space}:${module}:${collection}`;
|
||||
const docIds = await this.#sync.requestDocList(prefix);
|
||||
|
||||
const results = new Map<DocumentId, Automerge.Doc<T>>();
|
||||
for (const id of docIds) {
|
||||
const docId = id as DocumentId;
|
||||
const doc = await this.subscribe<T>(docId, schema);
|
||||
results.set(docId, doc);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for runtime status changes (online/offline/syncing/error).
|
||||
*/
|
||||
onStatusChange(cb: StatusCallback): () => void {
|
||||
this.#statusListeners.add(cb);
|
||||
return () => { this.#statusListeners.delete(cb); };
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush all pending writes to IndexedDB. Call on beforeunload.
|
||||
*/
|
||||
async flush(): Promise<void> {
|
||||
await Promise.all([
|
||||
this.#sync.flush(),
|
||||
this.#store.flush(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down: flush, disconnect, clear listeners.
|
||||
*/
|
||||
destroy(): void {
|
||||
this.#sync.disconnect();
|
||||
this.#statusListeners.clear();
|
||||
this.#initialized = false;
|
||||
this.#setStatus('idle');
|
||||
}
|
||||
|
||||
// ── Private ──
|
||||
|
||||
async #runStorageHousekeeping(): Promise<void> {
|
||||
try {
|
||||
// Request persistent storage (browser may grant silently)
|
||||
await requestPersistentStorage();
|
||||
|
||||
// Check quota and evict stale docs if needed
|
||||
const info = await getStorageInfo();
|
||||
if (info.percent >= QUOTA_WARNING_PERCENT) {
|
||||
console.warn(
|
||||
`[OfflineRuntime] Storage usage at ${info.percent}% ` +
|
||||
`(${(info.usage / 1e6).toFixed(1)}MB / ${(info.quota / 1e6).toFixed(1)}MB). ` +
|
||||
`Evicting stale documents...`
|
||||
);
|
||||
await evictStaleDocs();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[OfflineRuntime] Storage housekeeping failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
#setStatus(status: RuntimeStatus): void {
|
||||
if (this.#status === status) return;
|
||||
this.#status = status;
|
||||
for (const cb of this.#statusListeners) {
|
||||
try { cb(status); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
/**
|
||||
* Storage Quota Management — monitors IndexedDB usage and evicts stale docs.
|
||||
*
|
||||
* Uses the Storage API (navigator.storage.estimate) to check quota,
|
||||
* and evicts documents not accessed in 30+ days via LRU policy.
|
||||
*/
|
||||
|
||||
import type { DocumentId } from './document';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
export interface StorageInfo {
|
||||
/** Bytes used */
|
||||
usage: number;
|
||||
/** Total quota in bytes */
|
||||
quota: number;
|
||||
/** Usage as a percentage (0–100) */
|
||||
percent: number;
|
||||
/** Whether persistent storage is granted */
|
||||
persisted: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CONSTANTS
|
||||
// ============================================================================
|
||||
|
||||
/** Warn the user when storage usage exceeds this percentage */
|
||||
export const QUOTA_WARNING_PERCENT = 70;
|
||||
|
||||
/** Evict docs not accessed in this many milliseconds (30 days) */
|
||||
const STALE_THRESHOLD_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
// ============================================================================
|
||||
// PUBLIC API
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get current storage usage info.
|
||||
*/
|
||||
export async function getStorageInfo(): Promise<StorageInfo> {
|
||||
if (!navigator.storage?.estimate) {
|
||||
return { usage: 0, quota: 0, percent: 0, persisted: false };
|
||||
}
|
||||
|
||||
const [estimate, persisted] = await Promise.all([
|
||||
navigator.storage.estimate(),
|
||||
navigator.storage.persisted?.() ?? Promise.resolve(false),
|
||||
]);
|
||||
|
||||
const usage = estimate.usage ?? 0;
|
||||
const quota = estimate.quota ?? 0;
|
||||
const percent = quota > 0 ? Math.round((usage / quota) * 100) : 0;
|
||||
|
||||
return { usage, quota, percent, persisted };
|
||||
}
|
||||
|
||||
/**
|
||||
* Request persistent storage so the browser won't evict our data under pressure.
|
||||
* Returns true if granted.
|
||||
*/
|
||||
export async function requestPersistentStorage(): Promise<boolean> {
|
||||
if (!navigator.storage?.persist) return false;
|
||||
return navigator.storage.persist();
|
||||
}
|
||||
|
||||
/**
|
||||
* Evict stale documents from IndexedDB.
|
||||
* Removes docs whose `updatedAt` is older than 30 days.
|
||||
* Returns the list of evicted document IDs.
|
||||
*/
|
||||
export async function evictStaleDocs(dbName = 'rspace-docs'): Promise<DocumentId[]> {
|
||||
const evicted: DocumentId[] = [];
|
||||
const cutoff = Date.now() - STALE_THRESHOLD_MS;
|
||||
|
||||
let db: IDBDatabase;
|
||||
try {
|
||||
db = await openDb(dbName);
|
||||
} catch {
|
||||
return evicted;
|
||||
}
|
||||
|
||||
try {
|
||||
// Scan meta store for stale entries
|
||||
const staleIds = await new Promise<string[]>((resolve, reject) => {
|
||||
const tx = db.transaction('meta', 'readonly');
|
||||
const request = tx.objectStore('meta').openCursor();
|
||||
const ids: string[] = [];
|
||||
|
||||
request.onsuccess = () => {
|
||||
const cursor = request.result;
|
||||
if (!cursor) { resolve(ids); return; }
|
||||
|
||||
const meta = cursor.value as { docId: string; updatedAt: number };
|
||||
if (meta.updatedAt < cutoff) {
|
||||
ids.push(meta.docId);
|
||||
}
|
||||
cursor.continue();
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
// Delete stale docs + meta + sync states
|
||||
if (staleIds.length > 0) {
|
||||
const tx = db.transaction(['docs', 'meta', 'sync'], 'readwrite');
|
||||
const docsStore = tx.objectStore('docs');
|
||||
const metaStore = tx.objectStore('meta');
|
||||
|
||||
for (const id of staleIds) {
|
||||
docsStore.delete(id);
|
||||
metaStore.delete(id);
|
||||
evicted.push(id as DocumentId);
|
||||
}
|
||||
|
||||
// Clean sync states for evicted docs
|
||||
const syncStore = tx.objectStore('sync');
|
||||
const syncCursor = syncStore.openCursor();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
syncCursor.onsuccess = () => {
|
||||
const cursor = syncCursor.result;
|
||||
if (!cursor) { resolve(); return; }
|
||||
const key = cursor.key as string;
|
||||
if (staleIds.some(id => key.startsWith(`${id}\0`))) {
|
||||
cursor.delete();
|
||||
}
|
||||
cursor.continue();
|
||||
};
|
||||
syncCursor.onerror = () => reject(syncCursor.error);
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
|
||||
if (evicted.length > 0) {
|
||||
console.log(`[StorageQuota] Evicted ${evicted.length} stale documents`);
|
||||
}
|
||||
|
||||
return evicted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if storage usage is above the warning threshold.
|
||||
*/
|
||||
export async function isQuotaWarning(): Promise<boolean> {
|
||||
const info = await getStorageInfo();
|
||||
return info.percent >= QUOTA_WARNING_PERCENT;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HELPERS
|
||||
// ============================================================================
|
||||
|
||||
function openDb(name: string): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(name);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
|
@ -65,6 +65,21 @@ export interface RelayRestoreMessage {
|
|||
data: number[];
|
||||
}
|
||||
|
||||
/** Client requests doc IDs matching a prefix (for multi-doc modules). */
|
||||
export interface DocListRequestMessage {
|
||||
type: 'doc-list-request';
|
||||
prefix: string;
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
/** Server responds with matching doc IDs. */
|
||||
export interface DocListResponseMessage {
|
||||
type: 'doc-list-response';
|
||||
prefix: string;
|
||||
docIds: string[];
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
export type WireMessage =
|
||||
| SyncMessage
|
||||
| SubscribeMessage
|
||||
|
|
@ -72,6 +87,8 @@ export type WireMessage =
|
|||
| AwarenessMessage
|
||||
| RelayBackupMessage
|
||||
| RelayRestoreMessage
|
||||
| DocListRequestMessage
|
||||
| DocListResponseMessage
|
||||
| { type: 'ping' }
|
||||
| { type: 'pong' };
|
||||
|
||||
|
|
@ -89,6 +106,7 @@ export interface DocSyncManagerOptions {
|
|||
type DocChangeCallback = (doc: any) => void;
|
||||
type AwarenessCallback = (msg: AwarenessMessage) => void;
|
||||
type ConnectionCallback = () => void;
|
||||
type DocListCallback = (docIds: string[]) => void;
|
||||
|
||||
// ============================================================================
|
||||
// DocSyncManager (Client)
|
||||
|
|
@ -121,6 +139,9 @@ export class DocSyncManager {
|
|||
// Keep-alive
|
||||
#pingInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// Pending doc-list requests
|
||||
#docListCallbacks = new Map<string, DocListCallback>();
|
||||
|
||||
constructor(opts: DocSyncManagerOptions) {
|
||||
this.#documents = opts.documents;
|
||||
this.#store = opts.store ?? null;
|
||||
|
|
@ -299,6 +320,46 @@ export class DocSyncManager {
|
|||
} as AwarenessMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a list of doc IDs matching a prefix from the server.
|
||||
* Used by multi-doc modules to discover which documents exist.
|
||||
* Falls back to IndexedDB listing if offline.
|
||||
*/
|
||||
async requestDocList(prefix: string): Promise<string[]> {
|
||||
// Try IndexedDB first for instant offline results
|
||||
const parts = prefix.split(':');
|
||||
let localIds: string[] = [];
|
||||
if (this.#store && parts.length >= 2) {
|
||||
const module = parts[1];
|
||||
const collection = parts[2];
|
||||
localIds = await this.#store.listByModule(module, collection) as string[];
|
||||
localIds = localIds.filter(id => id.startsWith(prefix));
|
||||
}
|
||||
|
||||
// If connected, also ask the server
|
||||
if (this.isConnected) {
|
||||
const requestId = `req-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
const serverIds = await new Promise<string[]>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.#docListCallbacks.delete(requestId);
|
||||
resolve([]);
|
||||
}, 5000);
|
||||
this.#docListCallbacks.set(requestId, (ids) => {
|
||||
clearTimeout(timeout);
|
||||
this.#docListCallbacks.delete(requestId);
|
||||
resolve(ids);
|
||||
});
|
||||
this.#send({ type: 'doc-list-request', prefix, requestId });
|
||||
});
|
||||
|
||||
// Merge local + server (unique)
|
||||
const all = new Set([...localIds, ...serverIds]);
|
||||
return Array.from(all);
|
||||
}
|
||||
|
||||
return localIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for changes on a specific document.
|
||||
*/
|
||||
|
|
@ -416,6 +477,9 @@ export class DocSyncManager {
|
|||
case 'relay-restore':
|
||||
this.#handleRelayRestore(msg as RelayRestoreMessage);
|
||||
break;
|
||||
case 'doc-list-response':
|
||||
this.#handleDocListResponse(msg as DocListResponseMessage);
|
||||
break;
|
||||
case 'pong':
|
||||
// Keep-alive acknowledged
|
||||
break;
|
||||
|
|
@ -489,6 +553,11 @@ export class DocSyncManager {
|
|||
}
|
||||
}
|
||||
|
||||
#handleDocListResponse(msg: DocListResponseMessage): void {
|
||||
const cb = this.#docListCallbacks.get(msg.requestId);
|
||||
if (cb) cb(msg.docIds);
|
||||
}
|
||||
|
||||
#sendSyncMessage(docId: DocumentId): void {
|
||||
const doc = this.#documents.get(docId);
|
||||
if (!doc) return;
|
||||
|
|
|
|||
|
|
@ -14,8 +14,10 @@ import { RStackSpaceSwitcher } from "../shared/components/rstack-space-switcher"
|
|||
import { RStackTabBar } from "../shared/components/rstack-tab-bar";
|
||||
import { RStackMi } from "../shared/components/rstack-mi";
|
||||
import { RStackSpaceSettings } from "../shared/components/rstack-space-settings";
|
||||
import { RStackOfflineIndicator } from "../shared/components/rstack-offline-indicator";
|
||||
import { rspaceNavUrl } from "../shared/url-helpers";
|
||||
import { TabCache } from "../shared/tab-cache";
|
||||
import { RSpaceOfflineRuntime } from "../shared/local-first/runtime";
|
||||
|
||||
// Expose URL helper globally (used by shell inline scripts + components)
|
||||
(window as any).__rspaceNavUrl = rspaceNavUrl;
|
||||
|
|
@ -31,6 +33,24 @@ RStackSpaceSwitcher.define();
|
|||
RStackTabBar.define();
|
||||
RStackMi.define();
|
||||
RStackSpaceSettings.define();
|
||||
RStackOfflineIndicator.define();
|
||||
|
||||
// ── Offline Runtime ──
|
||||
// Instantiate the shared runtime from the space slug on the <body> tag.
|
||||
// Components access it via window.__rspaceOfflineRuntime.
|
||||
const spaceSlug = document.body?.getAttribute("data-space-slug");
|
||||
if (spaceSlug && spaceSlug !== "demo") {
|
||||
const runtime = new RSpaceOfflineRuntime(spaceSlug);
|
||||
(window as any).__rspaceOfflineRuntime = runtime;
|
||||
runtime.init().catch((e: unknown) => {
|
||||
console.warn("[shell] Offline runtime init failed — REST fallback only:", e);
|
||||
});
|
||||
|
||||
// Flush pending writes before the page unloads
|
||||
window.addEventListener("beforeunload", () => {
|
||||
runtime.flush();
|
||||
});
|
||||
}
|
||||
|
||||
// Reload space list when user signs in/out (to show/hide private spaces)
|
||||
document.addEventListener("auth-change", () => {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ declare const self: ServiceWorkerGlobalScope;
|
|||
const CACHE_VERSION = "rspace-v1";
|
||||
const STATIC_CACHE = `${CACHE_VERSION}-static`;
|
||||
const HTML_CACHE = `${CACHE_VERSION}-html`;
|
||||
const API_CACHE = `${CACHE_VERSION}-api`;
|
||||
|
||||
// Vite-hashed assets are immutable (content hash in filename)
|
||||
const IMMUTABLE_PATTERN = /\/assets\/.*\.[a-f0-9]{8}\.(js|css|wasm)$/;
|
||||
|
|
@ -11,6 +12,9 @@ const IMMUTABLE_PATTERN = /\/assets\/.*\.[a-f0-9]{8}\.(js|css|wasm)$/;
|
|||
// App shell to precache on install
|
||||
const PRECACHE_URLS = ["/", "/canvas.html"];
|
||||
|
||||
// Max age for cached API GET responses (5 minutes)
|
||||
const API_CACHE_MAX_AGE_MS = 5 * 60 * 1000;
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(HTML_CACHE).then((cache) => cache.addAll(PRECACHE_URLS))
|
||||
|
|
@ -40,16 +44,65 @@ self.addEventListener("fetch", (event) => {
|
|||
// Skip non-http(s) schemes (chrome-extension://, etc.) — they can't be cached
|
||||
if (!url.protocol.startsWith("http")) return;
|
||||
|
||||
// Skip WebSocket and API requests entirely (including module APIs like /space/module/api/...)
|
||||
// Skip WebSocket requests entirely
|
||||
if (
|
||||
event.request.url.startsWith("ws://") ||
|
||||
event.request.url.startsWith("wss://") ||
|
||||
url.pathname.startsWith("/ws/") ||
|
||||
url.pathname.includes("/api/")
|
||||
url.pathname.startsWith("/ws/")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// API GET requests: stale-while-revalidate for offline browsing
|
||||
// Skip POST/PUT/DELETE — those must go to the server
|
||||
if (url.pathname.includes("/api/")) {
|
||||
if (event.request.method !== "GET") return;
|
||||
|
||||
event.respondWith(
|
||||
caches.open(API_CACHE).then(async (cache) => {
|
||||
const cached = await cache.match(event.request);
|
||||
|
||||
const fetchPromise = fetch(event.request)
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
// Store with a timestamp header for staleness checking
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set("x-sw-cached-at", String(Date.now()));
|
||||
const timedResponse = new Response(response.clone().body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
});
|
||||
cache.put(event.request, timedResponse);
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => {
|
||||
// Offline — return cached if available
|
||||
if (cached) return cached;
|
||||
return new Response(
|
||||
JSON.stringify({ error: "offline", message: "You are offline and this data is not cached." }),
|
||||
{ status: 503, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
});
|
||||
|
||||
// Return cached immediately if fresh enough, revalidate in background
|
||||
if (cached) {
|
||||
const cachedAt = Number(cached.headers.get("x-sw-cached-at") || 0);
|
||||
if (Date.now() - cachedAt < API_CACHE_MAX_AGE_MS) {
|
||||
// Fresh cache — serve immediately, revalidate in background
|
||||
event.waitUntil(fetchPromise);
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
// No cache or stale — wait for network
|
||||
return fetchPromise;
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Immutable hashed assets: cache-first (they never change)
|
||||
if (IMMUTABLE_PATTERN.test(url.pathname)) {
|
||||
event.respondWith(
|
||||
|
|
@ -67,7 +120,7 @@ self.addEventListener("fetch", (event) => {
|
|||
return;
|
||||
}
|
||||
|
||||
// HTML pages: network-first with cache fallback
|
||||
// HTML pages: network-first with cache fallback + offline page
|
||||
if (
|
||||
event.request.mode === "navigate" ||
|
||||
event.request.headers.get("accept")?.includes("text/html")
|
||||
|
|
@ -84,7 +137,7 @@ self.addEventListener("fetch", (event) => {
|
|||
.catch(() => {
|
||||
return caches
|
||||
.match(event.request)
|
||||
.then((cached) => cached || caches.match("/canvas.html")) as Promise<Response>;
|
||||
.then((cached) => cached || caches.match("/") || offlineFallbackPage()) as Promise<Response>;
|
||||
})
|
||||
);
|
||||
return;
|
||||
|
|
@ -106,3 +159,39 @@ self.addEventListener("fetch", (event) => {
|
|||
})
|
||||
);
|
||||
});
|
||||
|
||||
/** Minimal offline fallback page when nothing is cached. */
|
||||
function offlineFallbackPage(): Response {
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Offline - rSpace</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; display: flex; align-items: center;
|
||||
justify-content: center; min-height: 100vh; margin: 0;
|
||||
background: #0a0a0f; color: #e0e0e0; }
|
||||
.offline { text-align: center; max-width: 400px; padding: 2rem; }
|
||||
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
|
||||
p { color: #999; line-height: 1.5; }
|
||||
button { margin-top: 1rem; padding: 8px 20px; border-radius: 6px;
|
||||
border: 1px solid #333; background: #1a1a2e; color: #e0e0e0;
|
||||
cursor: pointer; font-size: 0.9rem; }
|
||||
button:hover { background: #2a2a3e; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="offline">
|
||||
<h1>You're offline</h1>
|
||||
<p>rSpace can't reach the server right now. Previously visited pages and
|
||||
locally cached data are still available.</p>
|
||||
<button onclick="location.reload()">Try Again</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
return new Response(html, {
|
||||
status: 503,
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue