diff --git a/modules/rdata/local-first-client.ts b/modules/rdata/local-first-client.ts new file mode 100644 index 0000000..d942988 --- /dev/null +++ b/modules/rdata/local-first-client.ts @@ -0,0 +1,72 @@ +/** + * rData Local-First Client + * + * Syncs shared analytics dashboard config across space members. + */ + +import { DocumentManager } from '../../shared/local-first/document'; +import type { DocumentId } from '../../shared/local-first/document'; +import { EncryptedDocStore } from '../../shared/local-first/storage'; +import { DocSyncManager } from '../../shared/local-first/sync'; +import { DocCrypto } from '../../shared/local-first/crypto'; +import { dataSchema, dataDocId } from './schemas'; +import type { DataDoc, TrackedApp } from './schemas'; + +export class DataLocalFirstClient { + #space: string; + #documents: DocumentManager; + #store: EncryptedDocStore; + #sync: DocSyncManager; + #initialized = false; + + constructor(space: string, docCrypto?: DocCrypto) { + this.#space = space; + this.#documents = new DocumentManager(); + this.#store = new EncryptedDocStore(space, docCrypto); + this.#sync = new DocSyncManager({ documents: this.#documents, store: this.#store }); + this.#documents.registerSchema(dataSchema); + } + + get isConnected(): boolean { return this.#sync.isConnected; } + + async init(): Promise { + if (this.#initialized) return; + await this.#store.open(); + const cachedIds = await this.#store.listByModule('data', 'config'); + const cached = await this.#store.loadMany(cachedIds); + for (const [docId, binary] of cached) this.#documents.open(docId, dataSchema, binary); + await this.#sync.preloadSyncStates(cachedIds); + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${proto}//${location.host}/ws/${this.#space}`; + try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[DataClient] Working offline'); } + this.#initialized = true; + } + + async subscribe(): Promise { + const docId = dataDocId(this.#space) as DocumentId; + let doc = this.#documents.get(docId); + if (!doc) { + const binary = await this.#store.load(docId); + doc = binary + ? this.#documents.open(docId, dataSchema, binary) + : this.#documents.open(docId, dataSchema); + } + await this.#sync.subscribe([docId]); + return doc ?? null; + } + + getDoc(): DataDoc | undefined { return this.#documents.get(dataDocId(this.#space) as DocumentId); } + onChange(cb: (doc: DataDoc) => void): () => void { return this.#sync.onChange(dataDocId(this.#space) as DocumentId, cb as (doc: any) => void); } + + addTrackedApp(app: TrackedApp): void { + const docId = dataDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Track ${app.name}`, (d) => { d.trackedApps[app.id] = app; }); + } + + removeTrackedApp(appId: string): void { + const docId = dataDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Untrack app`, (d) => { delete d.trackedApps[appId]; }); + } + + async disconnect(): Promise { await this.#sync.flush(); this.#sync.disconnect(); } +} diff --git a/modules/rdata/schemas.ts b/modules/rdata/schemas.ts new file mode 100644 index 0000000..6126b96 --- /dev/null +++ b/modules/rdata/schemas.ts @@ -0,0 +1,70 @@ +/** + * rData Automerge document schemas. + * + * Lightweight sync for shared analytics dashboard configuration. + * The actual analytics data comes from the Umami API; this doc + * syncs which apps/metrics space members want to track together. + * + * DocId format: {space}:data:config + */ + +import type { DocSchema } from '../../shared/local-first/document'; + +// ── Document types ── + +export interface TrackedApp { + id: string; + name: string; + url: string; + addedBy: string | null; + addedAt: number; +} + +export interface DataDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + trackedApps: Record; + dashboardConfig: { + refreshInterval: number; + defaultDateRange: string; + }; +} + +// ── Schema registration ── + +export const dataSchema: DocSchema = { + module: 'data', + collection: 'config', + version: 1, + init: (): DataDoc => ({ + meta: { + module: 'data', + collection: 'config', + version: 1, + spaceSlug: '', + createdAt: Date.now(), + }, + trackedApps: {}, + dashboardConfig: { + refreshInterval: 30000, + defaultDateRange: '7d', + }, + }), + migrate: (doc: any, _fromVersion: number) => { + if (!doc.trackedApps) doc.trackedApps = {}; + if (!doc.dashboardConfig) doc.dashboardConfig = { refreshInterval: 30000, defaultDateRange: '7d' }; + doc.meta.version = 1; + return doc; + }, +}; + +// ── Helpers ── + +export function dataDocId(space: string) { + return `${space}:data:config` as const; +} diff --git a/modules/rphotos/local-first-client.ts b/modules/rphotos/local-first-client.ts new file mode 100644 index 0000000..628f601 --- /dev/null +++ b/modules/rphotos/local-first-client.ts @@ -0,0 +1,82 @@ +/** + * rPhotos Local-First Client + * + * Syncs shared album curation and photo annotations. + */ + +import { DocumentManager } from '../../shared/local-first/document'; +import type { DocumentId } from '../../shared/local-first/document'; +import { EncryptedDocStore } from '../../shared/local-first/storage'; +import { DocSyncManager } from '../../shared/local-first/sync'; +import { DocCrypto } from '../../shared/local-first/crypto'; +import { photosSchema, photosDocId } from './schemas'; +import type { PhotosDoc, SharedAlbum, PhotoAnnotation } from './schemas'; + +export class PhotosLocalFirstClient { + #space: string; + #documents: DocumentManager; + #store: EncryptedDocStore; + #sync: DocSyncManager; + #initialized = false; + + constructor(space: string, docCrypto?: DocCrypto) { + this.#space = space; + this.#documents = new DocumentManager(); + this.#store = new EncryptedDocStore(space, docCrypto); + this.#sync = new DocSyncManager({ documents: this.#documents, store: this.#store }); + this.#documents.registerSchema(photosSchema); + } + + get isConnected(): boolean { return this.#sync.isConnected; } + + async init(): Promise { + if (this.#initialized) return; + await this.#store.open(); + const cachedIds = await this.#store.listByModule('photos', 'albums'); + const cached = await this.#store.loadMany(cachedIds); + for (const [docId, binary] of cached) this.#documents.open(docId, photosSchema, binary); + await this.#sync.preloadSyncStates(cachedIds); + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${proto}//${location.host}/ws/${this.#space}`; + try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[PhotosClient] Working offline'); } + this.#initialized = true; + } + + async subscribe(): Promise { + const docId = photosDocId(this.#space) as DocumentId; + let doc = this.#documents.get(docId); + if (!doc) { + const binary = await this.#store.load(docId); + doc = binary + ? this.#documents.open(docId, photosSchema, binary) + : this.#documents.open(docId, photosSchema); + } + await this.#sync.subscribe([docId]); + return doc ?? null; + } + + getDoc(): PhotosDoc | undefined { return this.#documents.get(photosDocId(this.#space) as DocumentId); } + onChange(cb: (doc: PhotosDoc) => void): () => void { return this.#sync.onChange(photosDocId(this.#space) as DocumentId, cb as (doc: any) => void); } + + shareAlbum(album: SharedAlbum): void { + const docId = photosDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Share album ${album.name}`, (d) => { d.sharedAlbums[album.id] = album; }); + } + + unshareAlbum(albumId: string): void { + const docId = photosDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Unshare album`, (d) => { delete d.sharedAlbums[albumId]; }); + } + + annotatePhoto(annotation: PhotoAnnotation): void { + const docId = photosDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Annotate photo`, (d) => { d.annotations[annotation.assetId] = annotation; }); + } + + removeAnnotation(assetId: string): void { + const docId = photosDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Remove annotation`, (d) => { delete d.annotations[assetId]; }); + } + + async disconnect(): Promise { await this.#sync.flush(); this.#sync.disconnect(); } +} diff --git a/modules/rphotos/schemas.ts b/modules/rphotos/schemas.ts new file mode 100644 index 0000000..da58f68 --- /dev/null +++ b/modules/rphotos/schemas.ts @@ -0,0 +1,71 @@ +/** + * rPhotos Automerge document schemas. + * + * Syncs shared album curation and photo annotations across space members. + * Actual photo files remain on the Immich server; this doc manages + * which albums are shared and any collaborative annotations. + * + * DocId format: {space}:photos:albums + */ + +import type { DocSchema } from '../../shared/local-first/document'; + +// ── Document types ── + +export interface SharedAlbum { + id: string; + name: string; + description: string; + sharedBy: string | null; + sharedAt: number; +} + +export interface PhotoAnnotation { + assetId: string; + note: string; + authorDid: string; + createdAt: number; +} + +export interface PhotosDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + sharedAlbums: Record; + annotations: Record; +} + +// ── Schema registration ── + +export const photosSchema: DocSchema = { + module: 'photos', + collection: 'albums', + version: 1, + init: (): PhotosDoc => ({ + meta: { + module: 'photos', + collection: 'albums', + version: 1, + spaceSlug: '', + createdAt: Date.now(), + }, + sharedAlbums: {}, + annotations: {}, + }), + migrate: (doc: any, _fromVersion: number) => { + if (!doc.sharedAlbums) doc.sharedAlbums = {}; + if (!doc.annotations) doc.annotations = {}; + doc.meta.version = 1; + return doc; + }, +}; + +// ── Helpers ── + +export function photosDocId(space: string) { + return `${space}:photos:albums` as const; +} diff --git a/modules/rpubs/local-first-client.ts b/modules/rpubs/local-first-client.ts new file mode 100644 index 0000000..64bbbb1 --- /dev/null +++ b/modules/rpubs/local-first-client.ts @@ -0,0 +1,93 @@ +/** + * rPubs Local-First Client + * + * Wraps existing Automerge schemas for collaborative document editing. + * Each draft is a separate Automerge doc with shared markdown content. + */ + +import { DocumentManager } from '../../shared/local-first/document'; +import type { DocumentId } from '../../shared/local-first/document'; +import { EncryptedDocStore } from '../../shared/local-first/storage'; +import { DocSyncManager } from '../../shared/local-first/sync'; +import { DocCrypto } from '../../shared/local-first/crypto'; +import { pubsDraftSchema, pubsDocId } from './schemas'; +import type { PubsDoc, PubsDraftMeta } from './schemas'; + +export class PubsLocalFirstClient { + #space: string; + #documents: DocumentManager; + #store: EncryptedDocStore; + #sync: DocSyncManager; + #initialized = false; + + constructor(space: string, docCrypto?: DocCrypto) { + this.#space = space; + this.#documents = new DocumentManager(); + this.#store = new EncryptedDocStore(space, docCrypto); + this.#sync = new DocSyncManager({ documents: this.#documents, store: this.#store }); + this.#documents.registerSchema(pubsDraftSchema); + } + + get isConnected(): boolean { return this.#sync.isConnected; } + + async init(): Promise { + if (this.#initialized) return; + await this.#store.open(); + const cachedIds = await this.#store.listByModule('pubs', 'drafts'); + const cached = await this.#store.loadMany(cachedIds); + for (const [docId, binary] of cached) this.#documents.open(docId, pubsDraftSchema, binary); + await this.#sync.preloadSyncStates(cachedIds); + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${proto}//${location.host}/ws/${this.#space}`; + try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[PubsClient] Working offline'); } + this.#initialized = true; + } + + async subscribeDraft(draftId: string): Promise { + const docId = pubsDocId(this.#space, draftId) as DocumentId; + let doc = this.#documents.get(docId); + if (!doc) { + const binary = await this.#store.load(docId); + doc = binary + ? this.#documents.open(docId, pubsDraftSchema, binary) + : this.#documents.open(docId, pubsDraftSchema); + } + await this.#sync.subscribe([docId]); + return doc ?? null; + } + + getDraft(draftId: string): PubsDoc | undefined { + return this.#documents.get(pubsDocId(this.#space, draftId) as DocumentId); + } + + onDraftChange(draftId: string, cb: (doc: PubsDoc) => void): () => void { + return this.#sync.onChange(pubsDocId(this.#space, draftId) as DocumentId, cb as (doc: any) => void); + } + + /** List all cached draft IDs */ + async listDrafts(): Promise { + return this.#store.listByModule('pubs', 'drafts'); + } + + // ── Draft CRUD ── + + updateContent(draftId: string, content: string): void { + const docId = pubsDocId(this.#space, draftId) as DocumentId; + this.#sync.change(docId, `Update content`, (d) => { + d.content = content; + d.draft.updatedAt = Date.now(); + }); + } + + updateMeta(draftId: string, meta: Partial): void { + const docId = pubsDocId(this.#space, draftId) as DocumentId; + this.#sync.change(docId, `Update draft meta`, (d) => { + if (meta.title !== undefined) d.draft.title = meta.title; + if (meta.author !== undefined) d.draft.author = meta.author; + if (meta.format !== undefined) d.draft.format = meta.format; + d.draft.updatedAt = Date.now(); + }); + } + + async disconnect(): Promise { await this.#sync.flush(); this.#sync.disconnect(); } +} diff --git a/modules/rtube/local-first-client.ts b/modules/rtube/local-first-client.ts new file mode 100644 index 0000000..a2c6ef3 --- /dev/null +++ b/modules/rtube/local-first-client.ts @@ -0,0 +1,98 @@ +/** + * rTube Local-First Client + * + * Syncs shared playlists and watch party state. + */ + +import { DocumentManager } from '../../shared/local-first/document'; +import type { DocumentId } from '../../shared/local-first/document'; +import { EncryptedDocStore } from '../../shared/local-first/storage'; +import { DocSyncManager } from '../../shared/local-first/sync'; +import { DocCrypto } from '../../shared/local-first/crypto'; +import { tubeSchema, tubeDocId } from './schemas'; +import type { TubeDoc, Playlist, PlaylistEntry } from './schemas'; + +export class TubeLocalFirstClient { + #space: string; + #documents: DocumentManager; + #store: EncryptedDocStore; + #sync: DocSyncManager; + #initialized = false; + + constructor(space: string, docCrypto?: DocCrypto) { + this.#space = space; + this.#documents = new DocumentManager(); + this.#store = new EncryptedDocStore(space, docCrypto); + this.#sync = new DocSyncManager({ documents: this.#documents, store: this.#store }); + this.#documents.registerSchema(tubeSchema); + } + + get isConnected(): boolean { return this.#sync.isConnected; } + + async init(): Promise { + if (this.#initialized) return; + await this.#store.open(); + const cachedIds = await this.#store.listByModule('tube', 'playlists'); + const cached = await this.#store.loadMany(cachedIds); + for (const [docId, binary] of cached) this.#documents.open(docId, tubeSchema, binary); + await this.#sync.preloadSyncStates(cachedIds); + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${proto}//${location.host}/ws/${this.#space}`; + try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[TubeClient] Working offline'); } + this.#initialized = true; + } + + async subscribe(): Promise { + const docId = tubeDocId(this.#space) as DocumentId; + let doc = this.#documents.get(docId); + if (!doc) { + const binary = await this.#store.load(docId); + doc = binary + ? this.#documents.open(docId, tubeSchema, binary) + : this.#documents.open(docId, tubeSchema); + } + await this.#sync.subscribe([docId]); + return doc ?? null; + } + + getDoc(): TubeDoc | undefined { return this.#documents.get(tubeDocId(this.#space) as DocumentId); } + onChange(cb: (doc: TubeDoc) => void): () => void { return this.#sync.onChange(tubeDocId(this.#space) as DocumentId, cb as (doc: any) => void); } + + savePlaylist(playlist: Playlist): void { + const docId = tubeDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Save playlist ${playlist.name}`, (d) => { d.playlists[playlist.id] = playlist; }); + } + + deletePlaylist(playlistId: string): void { + const docId = tubeDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Delete playlist`, (d) => { delete d.playlists[playlistId]; }); + } + + addToPlaylist(playlistId: string, entry: PlaylistEntry): void { + const docId = tubeDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Add to playlist`, (d) => { + if (d.playlists[playlistId]) d.playlists[playlistId].entries.push(entry); + }); + } + + startWatchParty(videoName: string, startedBy: string | null): void { + const docId = tubeDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Start watch party`, (d) => { + d.watchParty = { videoName, startedBy, startedAt: Date.now(), position: 0, playing: true }; + }); + } + + updateWatchPartyPosition(position: number, playing: boolean): void { + const docId = tubeDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Sync playback`, (d) => { + if (d.watchParty) { d.watchParty.position = position; d.watchParty.playing = playing; } + }); + } + + endWatchParty(): void { + const docId = tubeDocId(this.#space) as DocumentId; + this.#sync.change(docId, `End watch party`, (d) => { d.watchParty = null; }); + } + + async disconnect(): Promise { await this.#sync.flush(); this.#sync.disconnect(); } +} diff --git a/modules/rtube/schemas.ts b/modules/rtube/schemas.ts new file mode 100644 index 0000000..fb491df --- /dev/null +++ b/modules/rtube/schemas.ts @@ -0,0 +1,79 @@ +/** + * rTube Automerge document schemas. + * + * Syncs shared playlists and watch party state across space members. + * Actual video files remain on R2/S3; this doc manages collaborative + * curation and viewing sessions. + * + * DocId format: {space}:tube:playlists + */ + +import type { DocSchema } from '../../shared/local-first/document'; + +// ── Document types ── + +export interface PlaylistEntry { + videoName: string; + addedBy: string | null; + addedAt: number; +} + +export interface Playlist { + id: string; + name: string; + entries: PlaylistEntry[]; + createdBy: string | null; + createdAt: number; +} + +export interface WatchParty { + videoName: string; + startedBy: string | null; + startedAt: number; + /** Playback position in seconds */ + position: number; + playing: boolean; +} + +export interface TubeDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + playlists: Record; + watchParty: WatchParty | null; +} + +// ── Schema registration ── + +export const tubeSchema: DocSchema = { + module: 'tube', + collection: 'playlists', + version: 1, + init: (): TubeDoc => ({ + meta: { + module: 'tube', + collection: 'playlists', + version: 1, + spaceSlug: '', + createdAt: Date.now(), + }, + playlists: {}, + watchParty: null, + }), + migrate: (doc: any, _fromVersion: number) => { + if (!doc.playlists) doc.playlists = {}; + if (!('watchParty' in doc)) doc.watchParty = null; + doc.meta.version = 1; + return doc; + }, +}; + +// ── Helpers ── + +export function tubeDocId(space: string) { + return `${space}:tube:playlists` as const; +}