diff --git a/modules/rswag/components/folk-swag-designer.ts b/modules/rswag/components/folk-swag-designer.ts index 098586d..dd991fd 100644 --- a/modules/rswag/components/folk-swag-designer.ts +++ b/modules/rswag/components/folk-swag-designer.ts @@ -148,6 +148,23 @@ function posterMockupSvg(): string { // --- Component --- import { TourEngine } from "../../../shared/tour-engine"; +import { SwagLocalFirstClient } from "../local-first-client"; +import type { SwagDoc, SwagDesign } from "../schemas"; + +// ── Auth helpers ── +function getSession(): { accessToken: string; claims: { sub: string; did?: string; username?: string } } | null { + try { + const raw = localStorage.getItem("encryptid_session"); + if (!raw) return null; + const s = JSON.parse(raw); + return s?.accessToken ? s : null; + } catch { return null; } +} +function getMyDid(): string | null { + const s = getSession(); + if (!s) return null; + return (s.claims as any).did || s.claims.sub; +} class FolkSwagDesigner extends HTMLElement { private shadow: ShadowRoot; @@ -164,6 +181,11 @@ class FolkSwagDesigner extends HTMLElement { private demoStep: 1 | 2 | 3 | 4 = 1; private progressStep = 0; private usedSampleDesign = false; + /* Multiplayer state */ + private lfClient: SwagLocalFirstClient | null = null; + private _lfcUnsub: (() => void) | null = null; + private sharedDesigns: SwagDesign[] = []; + private _tour!: TourEngine; private static readonly TOUR_STEPS = [ { target: '.product', title: "Choose Product", message: "Select a product type — tee, sticker, poster, or hoodie.", advanceOnClick: true }, @@ -193,6 +215,7 @@ class FolkSwagDesigner extends HTMLElement { this.demoStep = 1; this.render(); } else { + this.initMultiplayer(); this.render(); } if (!localStorage.getItem("rswag_tour_done")) { @@ -200,6 +223,58 @@ class FolkSwagDesigner extends HTMLElement { } } + disconnectedCallback() { + this._lfcUnsub?.(); + this._lfcUnsub = null; + this.lfClient?.disconnect(); + } + + private async initMultiplayer() { + try { + this.lfClient = new SwagLocalFirstClient(this.space); + await this.lfClient.init(); + await this.lfClient.subscribe(); + + this._lfcUnsub = this.lfClient.onChange((doc) => { + this.extractDesigns(doc); + this.render(); + }); + + const doc = this.lfClient.getDoc(); + if (doc) this.extractDesigns(doc); + this.render(); + } catch (err) { + console.warn('[rSwag] Local-first init failed:', err); + } + } + + private extractDesigns(doc: SwagDoc) { + this.sharedDesigns = doc.designs + ? Object.values(doc.designs).sort((a, b) => b.updatedAt - a.updatedAt) + : []; + } + + private saveDesignToSync(artifactId: string) { + if (!this.lfClient) return; + const design: SwagDesign = { + id: crypto.randomUUID(), + title: this.designTitle || 'Untitled Design', + productType: this.selectedProduct as SwagDesign['productType'], + artifactId, + createdBy: getMyDid(), + createdAt: Date.now(), + updatedAt: Date.now(), + }; + this.lfClient.saveDesign(design); + } + + private deleteSharedDesign(designId: string) { + if (!this.lfClient) return; + if (confirm('Delete this design?')) { + this.lfClient.deleteDesign(designId); + } + } + private getApiBase(): string { const path = window.location.pathname; const match = path.match(/^(\/[^/]+)?\/rswag/); @@ -338,6 +413,8 @@ class FolkSwagDesigner extends HTMLElement { } this.artifact = await res.json(); + // Sync design metadata to other space members + if (this.artifact?.id) this.saveDesignToSync(this.artifact.id); } catch (e) { this.error = e instanceof Error ? e.message : "Generation failed"; } finally { @@ -601,9 +678,27 @@ class FolkSwagDesigner extends HTMLElement { { id: "hoodie", name: "Hoodie", icon: "🧥", desc: '14x16" DTG print' }, ]; + const isLive = this.lfClient?.isConnected ?? false; + const productIcons: Record = { sticker: '📋', poster: '🖼', tee: '👕', hoodie: '🧥' }; + this.shadow.innerHTML = ` + ${isLive ? `
LIVE
` : ''} + + ${this.sharedDesigns.length > 0 ? ` +
Space Designs
+
+ ${this.sharedDesigns.map(d => ` +
+
${productIcons[d.productType] || '📋'}
+
${this.esc(d.title)}
+
${d.productType}${d.artifactId ? ' • ready' : ''}
+
+ ${d.artifactId ? `Download` : ''} + ${d.createdBy === getMyDid() ? `` : ''} +
+
`).join('')} +
+
+ ` : ''} + +
New Design
+
${products.map((p) => `
@@ -718,6 +835,14 @@ class FolkSwagDesigner extends HTMLElement { this.shadow.querySelector('[data-action="copy-json"]')?.addEventListener("click", () => { navigator.clipboard.writeText(JSON.stringify(this.artifact, null, 2)); }); + + // Shared design delete buttons + this.shadow.querySelectorAll('[data-delete-design]').forEach(btn => { + btn.addEventListener('click', () => { + const id = btn.dataset.deleteDesign; + if (id) this.deleteSharedDesign(id); + }); + }); } private getDemoStyles(): string { diff --git a/modules/rswag/local-first-client.ts b/modules/rswag/local-first-client.ts new file mode 100644 index 0000000..32028ae --- /dev/null +++ b/modules/rswag/local-first-client.ts @@ -0,0 +1,111 @@ +/** + * rSwag Local-First Client + * + * Wraps the shared local-first stack for collaborative design management. + */ + +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 { swagSchema, swagDocId } from './schemas'; +import type { SwagDoc, SwagDesign } from './schemas'; + +export class SwagLocalFirstClient { + #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(swagSchema); + } + + get isConnected(): boolean { return this.#sync.isConnected; } + + async init(): Promise { + if (this.#initialized) return; + await this.#store.open(); + const cachedIds = await this.#store.listByModule('swag', 'designs'); + const cached = await this.#store.loadMany(cachedIds); + for (const [docId, binary] of cached) { + this.#documents.open(docId, swagSchema, 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('[SwagClient] Working offline'); } + this.#initialized = true; + } + + async subscribe(): Promise { + const docId = swagDocId(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, swagSchema, binary) + : this.#documents.open(docId, swagSchema); + } + await this.#sync.subscribe([docId]); + return doc ?? null; + } + + getDoc(): SwagDoc | undefined { + return this.#documents.get(swagDocId(this.#space) as DocumentId); + } + + onChange(cb: (doc: SwagDoc) => void): () => void { + return this.#sync.onChange(swagDocId(this.#space) as DocumentId, cb as (doc: any) => void); + } + + onConnect(cb: () => void): () => void { return this.#sync.onConnect(cb); } + + // ── Design CRUD ── + + saveDesign(design: SwagDesign): void { + const docId = swagDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Save design ${design.title}`, (d) => { + d.designs[design.id] = design; + }); + } + + updateDesignArtifact(designId: string, artifactId: string): void { + const docId = swagDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Link artifact`, (d) => { + if (d.designs[designId]) { + d.designs[designId].artifactId = artifactId; + d.designs[designId].updatedAt = Date.now(); + } + }); + } + + deleteDesign(designId: string): void { + const docId = swagDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Delete design`, (d) => { + delete d.designs[designId]; + if (d.activeDesignId === designId) d.activeDesignId = null; + }); + } + + setActiveDesign(designId: string | null): void { + const docId = swagDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Set active design`, (d) => { + d.activeDesignId = designId; + }); + } + + async disconnect(): Promise { + await this.#sync.flush(); + this.#sync.disconnect(); + } +} diff --git a/modules/rswag/schemas.ts b/modules/rswag/schemas.ts new file mode 100644 index 0000000..6a04e17 --- /dev/null +++ b/modules/rswag/schemas.ts @@ -0,0 +1,68 @@ +/** + * rSwag Automerge document schemas. + * + * Stores shared design metadata for collaborative artifact management. + * Actual image files remain server-side; this doc syncs titles, product + * types, status, and artifact references across space members. + * + * DocId format: {space}:swag:designs + */ + +import type { DocSchema } from '../../shared/local-first/document'; + +// ── Document types ── + +export interface SwagDesign { + id: string; + title: string; + productType: 'sticker' | 'poster' | 'tee' | 'hoodie'; + /** Server artifact ID (if generated) */ + artifactId: string | null; + createdBy: string | null; + createdAt: number; + updatedAt: number; +} + +export interface SwagDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + designs: Record; + /** Currently active design ID for the space */ + activeDesignId: string | null; +} + +// ── Schema registration ── + +export const swagSchema: DocSchema = { + module: 'swag', + collection: 'designs', + version: 1, + init: (): SwagDoc => ({ + meta: { + module: 'swag', + collection: 'designs', + version: 1, + spaceSlug: '', + createdAt: Date.now(), + }, + designs: {}, + activeDesignId: null, + }), + migrate: (doc: any, _fromVersion: number) => { + if (!doc.designs) doc.designs = {}; + if (!('activeDesignId' in doc)) doc.activeDesignId = null; + doc.meta.version = 1; + return doc; + }, +}; + +// ── Helpers ── + +export function swagDocId(space: string) { + return `${space}:swag:designs` as const; +}