Merge branch 'dev'
This commit is contained in:
commit
afc66ac4c1
|
|
@ -5,6 +5,9 @@
|
||||||
* navigates to the flipbook reader. Authenticated users can upload.
|
* 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 {
|
interface BookData {
|
||||||
id: string;
|
id: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
|
|
@ -27,6 +30,7 @@ export class FolkBookShelf extends HTMLElement {
|
||||||
private _spaceSlug = "personal";
|
private _spaceSlug = "personal";
|
||||||
private _searchTerm = "";
|
private _searchTerm = "";
|
||||||
private _selectedTag: string | null = null;
|
private _selectedTag: string | null = null;
|
||||||
|
private _offlineUnsub: (() => void) | null = null;
|
||||||
|
|
||||||
static get observedAttributes() {
|
static get observedAttributes() {
|
||||||
return ["space-slug"];
|
return ["space-slug"];
|
||||||
|
|
@ -48,8 +52,49 @@ export class FolkBookShelf extends HTMLElement {
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
this.attachShadow({ mode: "open" });
|
this.attachShadow({ mode: "open" });
|
||||||
|
this._spaceSlug = this.getAttribute("space-slug") || this._spaceSlug;
|
||||||
this.render();
|
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() {
|
private loadDemoBooks() {
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,10 @@ function leafletZoomToSpatial(zoom: number): number {
|
||||||
if (zoom <= 15) return 6; if (zoom <= 17) return 7; return 8;
|
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 ──
|
// ── Component ──
|
||||||
|
|
||||||
class FolkCalendarView extends HTMLElement {
|
class FolkCalendarView extends HTMLElement {
|
||||||
|
|
@ -117,6 +121,7 @@ class FolkCalendarView extends HTMLElement {
|
||||||
private error = "";
|
private error = "";
|
||||||
private filteredSources = new Set<string>();
|
private filteredSources = new Set<string>();
|
||||||
private boundKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
private boundKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||||
|
private _offlineUnsub: (() => void) | null = null;
|
||||||
|
|
||||||
// Spatio-temporal state
|
// Spatio-temporal state
|
||||||
private temporalGranularity = 4; // MONTH
|
private temporalGranularity = 4; // MONTH
|
||||||
|
|
@ -150,11 +155,14 @@ class FolkCalendarView extends HTMLElement {
|
||||||
this.boundKeyHandler = (e: KeyboardEvent) => this.handleKeydown(e);
|
this.boundKeyHandler = (e: KeyboardEvent) => this.handleKeydown(e);
|
||||||
document.addEventListener("keydown", this.boundKeyHandler);
|
document.addEventListener("keydown", this.boundKeyHandler);
|
||||||
if (this.space === "demo") { this.loadDemoData(); return; }
|
if (this.space === "demo") { this.loadDemoData(); return; }
|
||||||
|
this.subscribeOffline();
|
||||||
this.loadMonth();
|
this.loadMonth();
|
||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
|
this._offlineUnsub?.();
|
||||||
|
this._offlineUnsub = null;
|
||||||
if (this.boundKeyHandler) {
|
if (this.boundKeyHandler) {
|
||||||
document.removeEventListener("keydown", this.boundKeyHandler);
|
document.removeEventListener("keydown", this.boundKeyHandler);
|
||||||
this.boundKeyHandler = null;
|
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 ──
|
// ── Keyboard ──
|
||||||
|
|
||||||
private handleKeydown(e: KeyboardEvent) {
|
private handleKeydown(e: KeyboardEvent) {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@
|
||||||
* Shows catalog items, order creation flow, and order status tracking.
|
* 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 {
|
class FolkCartShop extends HTMLElement {
|
||||||
private shadow: ShadowRoot;
|
private shadow: ShadowRoot;
|
||||||
private space = "default";
|
private space = "default";
|
||||||
|
|
@ -10,6 +13,7 @@ class FolkCartShop extends HTMLElement {
|
||||||
private orders: any[] = [];
|
private orders: any[] = [];
|
||||||
private view: "catalog" | "orders" = "catalog";
|
private view: "catalog" | "orders" = "catalog";
|
||||||
private loading = true;
|
private loading = true;
|
||||||
|
private _offlineUnsubs: (() => void)[] = [];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
@ -32,9 +36,67 @@ class FolkCartShop extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.render();
|
this.render();
|
||||||
|
this.subscribeOffline();
|
||||||
this.loadData();
|
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() {
|
private loadDemoData() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
this.catalog = [
|
this.catalog = [
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@
|
||||||
* space="slug" — shared space to browse (default: "default")
|
* 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 {
|
class FolkFileBrowser extends HTMLElement {
|
||||||
private shadow: ShadowRoot;
|
private shadow: ShadowRoot;
|
||||||
private space = "default";
|
private space = "default";
|
||||||
|
|
@ -12,6 +15,7 @@ class FolkFileBrowser extends HTMLElement {
|
||||||
private cards: any[] = [];
|
private cards: any[] = [];
|
||||||
private tab: "files" | "cards" = "files";
|
private tab: "files" | "cards" = "files";
|
||||||
private loading = false;
|
private loading = false;
|
||||||
|
private _offlineUnsubs: (() => void)[] = [];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
@ -27,10 +31,47 @@ class FolkFileBrowser extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.render();
|
this.render();
|
||||||
|
this.subscribeOffline();
|
||||||
this.loadFiles();
|
this.loadFiles();
|
||||||
this.loadCards();
|
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() {
|
private loadDemoData() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
this.files = [
|
this.files = [
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ import { PORT_DEFS, deriveThresholds } from "../lib/types";
|
||||||
import { computeSufficiencyState, computeSystemSufficiency, simulateTick } from "../lib/simulation";
|
import { computeSufficiencyState, computeSystemSufficiency, simulateTick } from "../lib/simulation";
|
||||||
import { demoNodes, SPENDING_COLORS, OVERFLOW_COLORS } from "../lib/presets";
|
import { demoNodes, SPENDING_COLORS, OVERFLOW_COLORS } from "../lib/presets";
|
||||||
import { mapFlowToNodes } from "../lib/map-flow";
|
import { mapFlowToNodes } from "../lib/map-flow";
|
||||||
|
import { flowsSchema, flowsDocId, type FlowsDoc } from "../schemas";
|
||||||
|
import type { DocumentId } from "../../../shared/local-first/document";
|
||||||
|
|
||||||
interface FlowSummary {
|
interface FlowSummary {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -83,6 +85,7 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
|
|
||||||
private loading = false;
|
private loading = false;
|
||||||
private error = "";
|
private error = "";
|
||||||
|
private _offlineUnsub: (() => void) | null = null;
|
||||||
|
|
||||||
// Canvas state
|
// Canvas state
|
||||||
private canvasZoom = 1;
|
private canvasZoom = 1;
|
||||||
|
|
@ -160,6 +163,50 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
this.view = "landing";
|
this.view = "landing";
|
||||||
this.loadFlows();
|
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 {
|
private getApiBase(): string {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@
|
||||||
* creating new instances.
|
* creating new instances.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { forumSchema, FORUM_DOC_ID, type ForumDoc } from "../schemas";
|
||||||
|
import type { DocumentId } from "../../../shared/local-first/document";
|
||||||
|
|
||||||
class FolkForumDashboard extends HTMLElement {
|
class FolkForumDashboard extends HTMLElement {
|
||||||
private shadow: ShadowRoot;
|
private shadow: ShadowRoot;
|
||||||
private instances: any[] = [];
|
private instances: any[] = [];
|
||||||
|
|
@ -14,6 +17,7 @@ class FolkForumDashboard extends HTMLElement {
|
||||||
private loading = false;
|
private loading = false;
|
||||||
private pollTimer: number | null = null;
|
private pollTimer: number | null = null;
|
||||||
private space = "";
|
private space = "";
|
||||||
|
private _offlineUnsub: (() => void) | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
@ -23,6 +27,7 @@ class FolkForumDashboard extends HTMLElement {
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
this.space = this.getAttribute("space") || "";
|
this.space = this.getAttribute("space") || "";
|
||||||
if (this.space === "demo") { this.loadDemoData(); return; }
|
if (this.space === "demo") { this.loadDemoData(); return; }
|
||||||
|
this.subscribeOffline();
|
||||||
this.render();
|
this.render();
|
||||||
this.loadInstances();
|
this.loadInstances();
|
||||||
}
|
}
|
||||||
|
|
@ -39,6 +44,41 @@ class FolkForumDashboard extends HTMLElement {
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
if (this.pollTimer) clearInterval(this.pollTimer);
|
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 {
|
private getApiBase(): string {
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,13 @@
|
||||||
* and approval workflow interface. Includes a help/guide popout.
|
* 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 {
|
class FolkInboxClient extends HTMLElement {
|
||||||
private shadow: ShadowRoot;
|
private shadow: ShadowRoot;
|
||||||
private space = "demo";
|
private space = "demo";
|
||||||
|
private _offlineUnsubs: (() => void)[] = [];
|
||||||
private view: "mailboxes" | "threads" | "thread" | "approvals" = "mailboxes";
|
private view: "mailboxes" | "threads" | "thread" | "approvals" = "mailboxes";
|
||||||
private mailboxes: any[] = [];
|
private mailboxes: any[] = [];
|
||||||
private threads: any[] = [];
|
private threads: any[] = [];
|
||||||
|
|
@ -73,9 +77,41 @@ class FolkInboxClient extends HTMLElement {
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
this.space = this.getAttribute("space") || "demo";
|
this.space = this.getAttribute("space") || "demo";
|
||||||
if (this.space === "demo") { this.loadDemoData(); return; }
|
if (this.space === "demo") { this.loadDemoData(); return; }
|
||||||
|
this.subscribeOffline();
|
||||||
this.loadMailboxes();
|
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() {
|
private loadDemoData() {
|
||||||
this.mailboxes = [
|
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 },
|
{ 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 * as Automerge from '@automerge/automerge';
|
||||||
|
import { notebookSchema } from '../schemas';
|
||||||
|
import type { DocumentId } from '../../../shared/local-first/document';
|
||||||
import { Editor } from '@tiptap/core';
|
import { Editor } from '@tiptap/core';
|
||||||
import StarterKit from '@tiptap/starter-kit';
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
import Link from '@tiptap/extension-link';
|
import Link from '@tiptap/extension-link';
|
||||||
|
|
@ -104,13 +106,12 @@ class FolkNotesApp extends HTMLElement {
|
||||||
private isRemoteUpdate = false;
|
private isRemoteUpdate = false;
|
||||||
private editorUpdateTimer: ReturnType<typeof setTimeout> | null = null;
|
private editorUpdateTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
// Automerge sync state
|
// Automerge sync state (via shared runtime)
|
||||||
private ws: WebSocket | null = null;
|
|
||||||
private doc: Automerge.Doc<NotebookDoc> | null = null;
|
private doc: Automerge.Doc<NotebookDoc> | null = null;
|
||||||
private syncState: Automerge.SyncState = Automerge.initSyncState();
|
|
||||||
private subscribedDocId: string | null = null;
|
private subscribedDocId: string | null = null;
|
||||||
private syncConnected = false;
|
private syncConnected = false;
|
||||||
private pingInterval: ReturnType<typeof setInterval> | null = null;
|
private _offlineUnsub: (() => void) | null = null;
|
||||||
|
private _offlineNotebookUnsubs: (() => void)[] = [];
|
||||||
|
|
||||||
// ── Demo data ──
|
// ── Demo data ──
|
||||||
private demoNotebooks: (Notebook & { notes: Note[] })[] = [];
|
private demoNotebooks: (Notebook & { notes: Note[] })[] = [];
|
||||||
|
|
@ -124,10 +125,48 @@ class FolkNotesApp extends HTMLElement {
|
||||||
this.space = this.getAttribute("space") || "demo";
|
this.space = this.getAttribute("space") || "demo";
|
||||||
this.setupShadow();
|
this.setupShadow();
|
||||||
if (this.space === "demo") { this.loadDemoData(); return; }
|
if (this.space === "demo") { this.loadDemoData(); return; }
|
||||||
this.connectSync();
|
this.subscribeOfflineRuntime();
|
||||||
this.loadNotebooks();
|
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() {
|
private setupShadow() {
|
||||||
const style = document.createElement('style');
|
const style = document.createElement('style');
|
||||||
style.textContent = this.getStyles();
|
style.textContent = this.getStyles();
|
||||||
|
|
@ -360,107 +399,51 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
this.destroyEditor();
|
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() {
|
private async subscribeNotebook(notebookId: string) {
|
||||||
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) {
|
|
||||||
this.subscribedDocId = `${this.space}:notes:notebooks:${notebookId}`;
|
this.subscribedDocId = `${this.space}:notes:notebooks:${notebookId}`;
|
||||||
this.doc = Automerge.init<NotebookDoc>();
|
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||||
this.syncState = Automerge.initSyncState();
|
|
||||||
|
|
||||||
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 unsub = runtime.onChange(docId, (updated: any) => {
|
||||||
|
this.doc = updated;
|
||||||
const [s, m] = Automerge.generateSyncMessage(this.doc, this.syncState);
|
this.renderFromDoc();
|
||||||
this.syncState = s;
|
});
|
||||||
if (m) {
|
this._offlineNotebookUnsubs.push(unsub);
|
||||||
this.ws.send(JSON.stringify({
|
} catch {
|
||||||
type: "sync", docId: this.subscribedDocId,
|
// Fallback: initialize empty doc for fresh notebook
|
||||||
data: Array.from(m),
|
this.doc = Automerge.init<NotebookDoc>();
|
||||||
}));
|
}
|
||||||
|
} else {
|
||||||
|
// No runtime — initialize empty doc
|
||||||
|
this.doc = Automerge.init<NotebookDoc>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private unsubscribeNotebook() {
|
private unsubscribeNotebook() {
|
||||||
if (this.subscribedDocId && this.ws?.readyState === WebSocket.OPEN) {
|
if (this.subscribedDocId) {
|
||||||
this.ws.send(JSON.stringify({ type: "unsubscribe", docIds: [this.subscribedDocId] }));
|
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||||
|
if (runtime?.isInitialized) {
|
||||||
|
runtime.unsubscribe(this.subscribedDocId as DocumentId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.subscribedDocId = null;
|
this.subscribedDocId = null;
|
||||||
this.doc = 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 */
|
/** 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 ──
|
// ── Automerge mutations ──
|
||||||
|
|
||||||
private createNoteViaSync() {
|
private createNoteViaSync() {
|
||||||
if (!this.doc || !this.selectedNotebook) return;
|
if (!this.doc || !this.selectedNotebook || !this.subscribedDocId) return;
|
||||||
|
|
||||||
const noteId = crypto.randomUUID();
|
const noteId = crypto.randomUUID();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
const notebookId = this.selectedNotebook.id;
|
||||||
|
|
||||||
this.doc = Automerge.change(this.doc, "Create note", (d: NotebookDoc) => {
|
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||||
if (!d.items) (d as any).items = {};
|
if (runtime?.isInitialized) {
|
||||||
d.items[noteId] = {
|
runtime.change(this.subscribedDocId as DocumentId, "Create note", (d: NotebookDoc) => {
|
||||||
id: noteId,
|
if (!d.items) (d as any).items = {};
|
||||||
notebookId: this.selectedNotebook!.id,
|
d.items[noteId] = {
|
||||||
title: "Untitled Note",
|
id: noteId, notebookId, title: "Untitled Note",
|
||||||
content: "",
|
content: "", contentPlain: "", contentFormat: "tiptap-json",
|
||||||
contentPlain: "",
|
type: "NOTE", tags: [], isPinned: false, sortOrder: 0,
|
||||||
contentFormat: "tiptap-json",
|
createdAt: now, updatedAt: now,
|
||||||
type: "NOTE",
|
};
|
||||||
tags: [],
|
});
|
||||||
isPinned: false,
|
this.doc = runtime.get(this.subscribedDocId as DocumentId);
|
||||||
sortOrder: 0,
|
} else {
|
||||||
createdAt: now,
|
this.doc = Automerge.change(this.doc, "Create note", (d: NotebookDoc) => {
|
||||||
updatedAt: now,
|
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();
|
this.renderFromDoc();
|
||||||
|
|
||||||
// Open the new note
|
// 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) {
|
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) => {
|
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||||
(d.items[noteId] as any)[field] = value;
|
if (runtime?.isInitialized) {
|
||||||
d.items[noteId].updatedAt = Date.now();
|
runtime.change(this.subscribedDocId as DocumentId, `Update ${field}`, (d: NotebookDoc) => {
|
||||||
});
|
(d.items[noteId] as any)[field] = value;
|
||||||
|
d.items[noteId].updatedAt = Date.now();
|
||||||
this.sendSyncAfterChange();
|
});
|
||||||
}
|
this.doc = runtime.get(this.subscribedDocId as DocumentId);
|
||||||
|
} else {
|
||||||
private sendSyncAfterChange() {
|
this.doc = Automerge.change(this.doc, `Update ${field}`, (d: NotebookDoc) => {
|
||||||
if (!this.doc || !this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
(d.items[noteId] as any)[field] = value;
|
||||||
|
d.items[noteId].updatedAt = Date.now();
|
||||||
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),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,12 @@
|
||||||
* <folk-schedule-app> — schedule management UI.
|
* <folk-schedule-app> — schedule management UI.
|
||||||
*
|
*
|
||||||
* Job list with create/edit forms, execution log viewer,
|
* 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 {
|
interface JobData {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -83,6 +86,7 @@ class FolkScheduleApp extends HTMLElement {
|
||||||
private editingReminder: ReminderData | null = null;
|
private editingReminder: ReminderData | null = null;
|
||||||
private loading = false;
|
private loading = false;
|
||||||
private runningJobId: string | null = null;
|
private runningJobId: string | null = null;
|
||||||
|
private _offlineUnsub: (() => void) | null = null;
|
||||||
|
|
||||||
// Reminder form state
|
// Reminder form state
|
||||||
private rFormTitle = "";
|
private rFormTitle = "";
|
||||||
|
|
@ -109,9 +113,89 @@ class FolkScheduleApp extends HTMLElement {
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
this.space = this.getAttribute("space") || "demo";
|
this.space = this.getAttribute("space") || "demo";
|
||||||
|
this.subscribeOffline();
|
||||||
this.loadJobs();
|
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 {
|
private getApiBase(): string {
|
||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
const match = path.match(/^(\/[^/]+)?\/rschedule/);
|
const match = path.match(/^(\/[^/]+)?\/rschedule/);
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@
|
||||||
* Three.js and GaussianSplats3D are loaded via CDN importmap (not bundled).
|
* 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 {
|
interface SplatItem {
|
||||||
id: string;
|
id: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
|
|
@ -30,6 +33,7 @@ export class FolkSplatViewer extends HTMLElement {
|
||||||
private _splatDesc = "";
|
private _splatDesc = "";
|
||||||
private _viewer: any = null;
|
private _viewer: any = null;
|
||||||
private _uploadMode: "splat" | "media" = "splat";
|
private _uploadMode: "splat" | "media" = "splat";
|
||||||
|
private _offlineUnsub: (() => void) | null = null;
|
||||||
|
|
||||||
static get observedAttributes() {
|
static get observedAttributes() {
|
||||||
return ["mode", "splat-url", "splat-title", "splat-desc", "space-slug"];
|
return ["mode", "splat-url", "splat-title", "splat-desc", "space-slug"];
|
||||||
|
|
@ -54,11 +58,46 @@ export class FolkSplatViewer extends HTMLElement {
|
||||||
if (this._mode === "viewer") {
|
if (this._mode === "viewer") {
|
||||||
this.renderViewer();
|
this.renderViewer();
|
||||||
} else {
|
} else {
|
||||||
if (this._spaceSlug === "demo") this.loadDemoData();
|
if (this._spaceSlug === "demo") {
|
||||||
|
this.loadDemoData();
|
||||||
|
} else {
|
||||||
|
this.subscribeOffline();
|
||||||
|
}
|
||||||
this.renderGallery();
|
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() {
|
private loadDemoData() {
|
||||||
this._splats = [
|
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" },
|
{ 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() {
|
disconnectedCallback() {
|
||||||
|
this._offlineUnsub?.();
|
||||||
|
this._offlineUnsub = null;
|
||||||
if (this._viewer) {
|
if (this._viewer) {
|
||||||
try { this._viewer.dispose(); } catch {}
|
try { this._viewer.dispose(); } catch {}
|
||||||
this._viewer = null;
|
this._viewer = null;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@
|
||||||
* Demo: 4 trips with varied statuses and rich destination chains.
|
* 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 {
|
class FolkTripsPlanner extends HTMLElement {
|
||||||
private shadow: ShadowRoot;
|
private shadow: ShadowRoot;
|
||||||
private space = "";
|
private space = "";
|
||||||
|
|
@ -14,6 +17,7 @@ class FolkTripsPlanner extends HTMLElement {
|
||||||
private trip: any = null;
|
private trip: any = null;
|
||||||
private tab: "overview" | "destinations" | "itinerary" | "bookings" | "expenses" | "packing" = "overview";
|
private tab: "overview" | "destinations" | "itinerary" | "bookings" | "expenses" | "packing" = "overview";
|
||||||
private error = "";
|
private error = "";
|
||||||
|
private _offlineUnsubs: (() => void)[] = [];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
@ -23,10 +27,43 @@ class FolkTripsPlanner extends HTMLElement {
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
this.space = this.getAttribute("space") || "demo";
|
this.space = this.getAttribute("space") || "demo";
|
||||||
if (this.space === "demo") { this.loadDemoData(); return; }
|
if (this.space === "demo") { this.loadDemoData(); return; }
|
||||||
|
this.subscribeOffline();
|
||||||
this.loadTrips();
|
this.loadTrips();
|
||||||
this.render();
|
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() {
|
private loadDemoData() {
|
||||||
this.trips = [
|
this.trips = [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@
|
||||||
* Browse spaces, create/view proposals, cast votes (ranking + final).
|
* 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 {
|
interface VoteSpace {
|
||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -40,6 +43,7 @@ class FolkVoteDashboard extends HTMLElement {
|
||||||
private selectedProposal: Proposal | null = null;
|
private selectedProposal: Proposal | null = null;
|
||||||
private loading = false;
|
private loading = false;
|
||||||
private error = "";
|
private error = "";
|
||||||
|
private _offlineUnsubs: (() => void)[] = [];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
@ -49,9 +53,46 @@ class FolkVoteDashboard extends HTMLElement {
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
this.space = this.getAttribute("space") || "demo";
|
this.space = this.getAttribute("space") || "demo";
|
||||||
if (this.space === "demo") { this.loadDemoData(); return; }
|
if (this.space === "demo") { this.loadDemoData(); return; }
|
||||||
|
this.subscribeOffline();
|
||||||
this.loadSpaces();
|
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() {
|
private loadDemoData() {
|
||||||
this.spaces = [
|
this.spaces = [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@
|
||||||
* Supports task creation, status changes, and priority labels.
|
* 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 {
|
class FolkWorkBoard extends HTMLElement {
|
||||||
private shadow: ShadowRoot;
|
private shadow: ShadowRoot;
|
||||||
private space = "";
|
private space = "";
|
||||||
|
|
@ -20,6 +23,7 @@ class FolkWorkBoard extends HTMLElement {
|
||||||
private editingTaskId: string | null = null;
|
private editingTaskId: string | null = null;
|
||||||
private showCreateForm = false;
|
private showCreateForm = false;
|
||||||
private priorities = ["LOW", "MEDIUM", "HIGH", "URGENT"];
|
private priorities = ["LOW", "MEDIUM", "HIGH", "URGENT"];
|
||||||
|
private _offlineUnsubs: (() => void)[] = [];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
@ -29,10 +33,55 @@ class FolkWorkBoard extends HTMLElement {
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
this.space = this.getAttribute("space") || "demo";
|
this.space = this.getAttribute("space") || "demo";
|
||||||
if (this.space === "demo") { this.loadDemoData(); return; }
|
if (this.space === "demo") { this.loadDemoData(); return; }
|
||||||
|
this.subscribeOffline();
|
||||||
this.loadWorkspaces();
|
this.loadWorkspaces();
|
||||||
this.render();
|
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() {
|
private loadDemoData() {
|
||||||
this.isDemo = true;
|
this.isDemo = true;
|
||||||
this.workspaces = [{ slug: "rspace-dev", name: "rSpace Development", icon: "\u{1F680}", task_count: 11, member_count: 2 }];
|
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[];
|
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 =
|
type WireMessage =
|
||||||
| SyncMessage
|
| SyncMessage
|
||||||
| SubscribeMessage
|
| SubscribeMessage
|
||||||
|
|
@ -69,6 +82,8 @@ type WireMessage =
|
||||||
| AwarenessMessage
|
| AwarenessMessage
|
||||||
| RelayBackupMessage
|
| RelayBackupMessage
|
||||||
| RelayRestoreMessage
|
| RelayRestoreMessage
|
||||||
|
| DocListRequestMessage
|
||||||
|
| DocListResponseMessage
|
||||||
| { type: 'ping' }
|
| { type: 'ping' }
|
||||||
| { type: 'pong' };
|
| { type: 'pong' };
|
||||||
|
|
||||||
|
|
@ -199,6 +214,9 @@ export class SyncServer {
|
||||||
case 'relay-backup':
|
case 'relay-backup':
|
||||||
this.#handleRelayBackup(peer, msg as RelayBackupMessage);
|
this.#handleRelayBackup(peer, msg as RelayBackupMessage);
|
||||||
break;
|
break;
|
||||||
|
case 'doc-list-request':
|
||||||
|
this.#handleDocListRequest(peer, msg as DocListRequestMessage);
|
||||||
|
break;
|
||||||
case 'ping':
|
case 'ping':
|
||||||
this.#sendToPeer(peer, { type: 'pong' });
|
this.#sendToPeer(peer, { type: 'pong' });
|
||||||
break;
|
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 {
|
#sendSyncToPeer(peer: Peer, docId: string): void {
|
||||||
const doc = this.#docs.get(docId);
|
const doc = this.#docs.get(docId);
|
||||||
if (!doc) return;
|
if (!doc) return;
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,7 @@ export function renderShell(opts: ShellOptions): string {
|
||||||
<style>${WELCOME_CSS}</style>
|
<style>${WELCOME_CSS}</style>
|
||||||
<style>${ACCESS_GATE_CSS}</style>
|
<style>${ACCESS_GATE_CSS}</style>
|
||||||
</head>
|
</head>
|
||||||
<body data-space-visibility="${escapeAttr(spaceVisibility)}">
|
<body data-space-visibility="${escapeAttr(spaceVisibility)}" data-space-slug="${escapeAttr(spaceSlug)}">
|
||||||
<header class="rstack-header">
|
<header class="rstack-header">
|
||||||
<div class="rstack-header__left">
|
<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>
|
<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>
|
||||||
<div class="rstack-header__right">
|
<div class="rstack-header__right">
|
||||||
<a class="rstack-header__demo-btn" ${spaceSlug === "demo" ? 'data-hide' : ''} href="${shellDemoUrl}">Try Demo</a>
|
<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-notification-bell></rstack-notification-bell>
|
||||||
<rstack-identity></rstack-identity>
|
<rstack-identity></rstack-identity>
|
||||||
</div>
|
</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,
|
type WireMessage,
|
||||||
} from './sync';
|
} 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
|
// Backup
|
||||||
export {
|
export {
|
||||||
BackupSyncManager,
|
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[];
|
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 =
|
export type WireMessage =
|
||||||
| SyncMessage
|
| SyncMessage
|
||||||
| SubscribeMessage
|
| SubscribeMessage
|
||||||
|
|
@ -72,6 +87,8 @@ export type WireMessage =
|
||||||
| AwarenessMessage
|
| AwarenessMessage
|
||||||
| RelayBackupMessage
|
| RelayBackupMessage
|
||||||
| RelayRestoreMessage
|
| RelayRestoreMessage
|
||||||
|
| DocListRequestMessage
|
||||||
|
| DocListResponseMessage
|
||||||
| { type: 'ping' }
|
| { type: 'ping' }
|
||||||
| { type: 'pong' };
|
| { type: 'pong' };
|
||||||
|
|
||||||
|
|
@ -89,6 +106,7 @@ export interface DocSyncManagerOptions {
|
||||||
type DocChangeCallback = (doc: any) => void;
|
type DocChangeCallback = (doc: any) => void;
|
||||||
type AwarenessCallback = (msg: AwarenessMessage) => void;
|
type AwarenessCallback = (msg: AwarenessMessage) => void;
|
||||||
type ConnectionCallback = () => void;
|
type ConnectionCallback = () => void;
|
||||||
|
type DocListCallback = (docIds: string[]) => void;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// DocSyncManager (Client)
|
// DocSyncManager (Client)
|
||||||
|
|
@ -121,6 +139,9 @@ export class DocSyncManager {
|
||||||
// Keep-alive
|
// Keep-alive
|
||||||
#pingInterval: ReturnType<typeof setInterval> | null = null;
|
#pingInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
// Pending doc-list requests
|
||||||
|
#docListCallbacks = new Map<string, DocListCallback>();
|
||||||
|
|
||||||
constructor(opts: DocSyncManagerOptions) {
|
constructor(opts: DocSyncManagerOptions) {
|
||||||
this.#documents = opts.documents;
|
this.#documents = opts.documents;
|
||||||
this.#store = opts.store ?? null;
|
this.#store = opts.store ?? null;
|
||||||
|
|
@ -299,6 +320,46 @@ export class DocSyncManager {
|
||||||
} as AwarenessMessage);
|
} 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.
|
* Listen for changes on a specific document.
|
||||||
*/
|
*/
|
||||||
|
|
@ -416,6 +477,9 @@ export class DocSyncManager {
|
||||||
case 'relay-restore':
|
case 'relay-restore':
|
||||||
this.#handleRelayRestore(msg as RelayRestoreMessage);
|
this.#handleRelayRestore(msg as RelayRestoreMessage);
|
||||||
break;
|
break;
|
||||||
|
case 'doc-list-response':
|
||||||
|
this.#handleDocListResponse(msg as DocListResponseMessage);
|
||||||
|
break;
|
||||||
case 'pong':
|
case 'pong':
|
||||||
// Keep-alive acknowledged
|
// Keep-alive acknowledged
|
||||||
break;
|
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 {
|
#sendSyncMessage(docId: DocumentId): void {
|
||||||
const doc = this.#documents.get(docId);
|
const doc = this.#documents.get(docId);
|
||||||
if (!doc) return;
|
if (!doc) return;
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,10 @@ import { RStackSpaceSwitcher } from "../shared/components/rstack-space-switcher"
|
||||||
import { RStackTabBar } from "../shared/components/rstack-tab-bar";
|
import { RStackTabBar } from "../shared/components/rstack-tab-bar";
|
||||||
import { RStackMi } from "../shared/components/rstack-mi";
|
import { RStackMi } from "../shared/components/rstack-mi";
|
||||||
import { RStackSpaceSettings } from "../shared/components/rstack-space-settings";
|
import { RStackSpaceSettings } from "../shared/components/rstack-space-settings";
|
||||||
|
import { RStackOfflineIndicator } from "../shared/components/rstack-offline-indicator";
|
||||||
import { rspaceNavUrl } from "../shared/url-helpers";
|
import { rspaceNavUrl } from "../shared/url-helpers";
|
||||||
import { TabCache } from "../shared/tab-cache";
|
import { TabCache } from "../shared/tab-cache";
|
||||||
|
import { RSpaceOfflineRuntime } from "../shared/local-first/runtime";
|
||||||
|
|
||||||
// Expose URL helper globally (used by shell inline scripts + components)
|
// Expose URL helper globally (used by shell inline scripts + components)
|
||||||
(window as any).__rspaceNavUrl = rspaceNavUrl;
|
(window as any).__rspaceNavUrl = rspaceNavUrl;
|
||||||
|
|
@ -31,6 +33,24 @@ RStackSpaceSwitcher.define();
|
||||||
RStackTabBar.define();
|
RStackTabBar.define();
|
||||||
RStackMi.define();
|
RStackMi.define();
|
||||||
RStackSpaceSettings.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)
|
// Reload space list when user signs in/out (to show/hide private spaces)
|
||||||
document.addEventListener("auth-change", () => {
|
document.addEventListener("auth-change", () => {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ declare const self: ServiceWorkerGlobalScope;
|
||||||
const CACHE_VERSION = "rspace-v1";
|
const CACHE_VERSION = "rspace-v1";
|
||||||
const STATIC_CACHE = `${CACHE_VERSION}-static`;
|
const STATIC_CACHE = `${CACHE_VERSION}-static`;
|
||||||
const HTML_CACHE = `${CACHE_VERSION}-html`;
|
const HTML_CACHE = `${CACHE_VERSION}-html`;
|
||||||
|
const API_CACHE = `${CACHE_VERSION}-api`;
|
||||||
|
|
||||||
// Vite-hashed assets are immutable (content hash in filename)
|
// Vite-hashed assets are immutable (content hash in filename)
|
||||||
const IMMUTABLE_PATTERN = /\/assets\/.*\.[a-f0-9]{8}\.(js|css|wasm)$/;
|
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
|
// App shell to precache on install
|
||||||
const PRECACHE_URLS = ["/", "/canvas.html"];
|
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) => {
|
self.addEventListener("install", (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.open(HTML_CACHE).then((cache) => cache.addAll(PRECACHE_URLS))
|
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
|
// Skip non-http(s) schemes (chrome-extension://, etc.) — they can't be cached
|
||||||
if (!url.protocol.startsWith("http")) return;
|
if (!url.protocol.startsWith("http")) return;
|
||||||
|
|
||||||
// Skip WebSocket and API requests entirely (including module APIs like /space/module/api/...)
|
// Skip WebSocket requests entirely
|
||||||
if (
|
if (
|
||||||
event.request.url.startsWith("ws://") ||
|
event.request.url.startsWith("ws://") ||
|
||||||
event.request.url.startsWith("wss://") ||
|
event.request.url.startsWith("wss://") ||
|
||||||
url.pathname.startsWith("/ws/") ||
|
url.pathname.startsWith("/ws/")
|
||||||
url.pathname.includes("/api/")
|
|
||||||
) {
|
) {
|
||||||
return;
|
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)
|
// Immutable hashed assets: cache-first (they never change)
|
||||||
if (IMMUTABLE_PATTERN.test(url.pathname)) {
|
if (IMMUTABLE_PATTERN.test(url.pathname)) {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
|
|
@ -67,7 +120,7 @@ self.addEventListener("fetch", (event) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTML pages: network-first with cache fallback
|
// HTML pages: network-first with cache fallback + offline page
|
||||||
if (
|
if (
|
||||||
event.request.mode === "navigate" ||
|
event.request.mode === "navigate" ||
|
||||||
event.request.headers.get("accept")?.includes("text/html")
|
event.request.headers.get("accept")?.includes("text/html")
|
||||||
|
|
@ -84,7 +137,7 @@ self.addEventListener("fetch", (event) => {
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
return caches
|
return caches
|
||||||
.match(event.request)
|
.match(event.request)
|
||||||
.then((cached) => cached || caches.match("/canvas.html")) as Promise<Response>;
|
.then((cached) => cached || caches.match("/") || offlineFallbackPage()) as Promise<Response>;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
return;
|
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