feat(rswag): add multiplayer design sync via Automerge CRDT
Shared design metadata syncs across space members in real-time. "Space Designs" gallery shows all designs with download links. Artifact generation auto-publishes design metadata to peers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b67f30ac0a
commit
0db93695d8
|
|
@ -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<string, string> = { sticker: '📋', poster: '🖼', tee: '👕', hoodie: '🧥' };
|
||||
|
||||
this.shadow.innerHTML = `
|
||||
<style>
|
||||
:host { display: block; padding: 1.5rem; max-width: 900px; margin: 0 auto; }
|
||||
.live-header { display: flex; align-items: center; gap: 8px; margin-bottom: 1rem; }
|
||||
.live-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #22c55e; animation: pulse-dot 2s infinite; }
|
||||
@keyframes pulse-dot { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||
.live-badge { font-size: 0.7rem; padding: 2px 8px; border-radius: 999px; background: rgba(34,197,94,0.15); color: #22c55e; font-weight: 500; display: flex; align-items: center; gap: 2px; }
|
||||
.section-label { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--rs-text-muted); margin-bottom: 0.75rem; font-weight: 600; }
|
||||
.designs-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.75rem; margin-bottom: 1.5rem; }
|
||||
.design-card { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 10px; padding: 0.75rem; transition: border-color 0.15s; }
|
||||
.design-card:hover { border-color: var(--rs-primary); }
|
||||
.design-card-icon { font-size: 1.5rem; margin-bottom: 4px; }
|
||||
.design-card-title { font-weight: 600; font-size: 0.875rem; color: var(--rs-text-primary); margin-bottom: 2px; }
|
||||
.design-card-meta { font-size: 0.75rem; color: var(--rs-text-secondary); }
|
||||
.design-card-actions { margin-top: 6px; }
|
||||
.design-delete { font-size: 0.7rem; color: var(--rs-text-muted); cursor: pointer; border: none; background: none; padding: 2px 6px; border-radius: 4px; font-family: inherit; }
|
||||
.design-delete:hover { color: #fca5a5; background: rgba(239,68,68,0.1); }
|
||||
.divider { border: none; border-top: 1px solid var(--rs-border); margin: 1.5rem 0; }
|
||||
.products { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.75rem; margin-bottom: 1.5rem; }
|
||||
.product { padding: 1rem; border-radius: 12px; border: 2px solid var(--rs-border); background: var(--rs-bg-surface); cursor: pointer; text-align: center; transition: all 0.15s; }
|
||||
.product:hover { border-color: var(--rs-border-strong); }
|
||||
|
|
@ -618,7 +713,7 @@ class FolkSwagDesigner extends HTMLElement {
|
|||
.preview-img { max-width: 200px; max-height: 200px; border-radius: 8px; }
|
||||
.title-input { width: 100%; padding: 0.625rem 0.75rem; border: 1px solid var(--rs-input-border); border-radius: 8px; background: var(--rs-input-bg); color: var(--rs-input-text); font-size: 0.875rem; margin-bottom: 1rem; box-sizing: border-box; }
|
||||
.title-input:focus { outline: none; border-color: var(--rs-primary); }
|
||||
.generate-btn { width: 100%; padding: 0.75rem; border: none; border-radius: 8px; background: var(--rs-primary-hover); color: #fff; font-size: 1rem; font-weight: 600; cursor: pointer; margin-bottom: 1rem; }
|
||||
.generate-btn { width: 100%; padding: 0.75rem; border: none; border-radius: 8px; background: var(--rs-primary-hover); color: #fff; font-size: 1rem; font-weight: 600; cursor: pointer; margin-bottom: 1rem; font-family: inherit; }
|
||||
.generate-btn:hover { background: #4338ca; }
|
||||
.generate-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.error { background: rgba(239,68,68,0.1); border: 1px solid var(--rs-error); border-radius: 8px; padding: 0.75rem; color: #fca5a5; font-size: 0.875rem; margin-bottom: 1rem; }
|
||||
|
|
@ -637,9 +732,31 @@ class FolkSwagDesigner extends HTMLElement {
|
|||
input[type="file"] { display: none; }
|
||||
@media (max-width: 768px) {
|
||||
.products { grid-template-columns: repeat(2, 1fr); }
|
||||
.designs-grid { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); }
|
||||
}
|
||||
</style>
|
||||
|
||||
${isLive ? `<div class="live-header"><span class="live-badge"><span class="live-dot"></span>LIVE</span></div>` : ''}
|
||||
|
||||
${this.sharedDesigns.length > 0 ? `
|
||||
<div class="section-label">Space Designs</div>
|
||||
<div class="designs-grid">
|
||||
${this.sharedDesigns.map(d => `
|
||||
<div class="design-card">
|
||||
<div class="design-card-icon">${productIcons[d.productType] || '📋'}</div>
|
||||
<div class="design-card-title">${this.esc(d.title)}</div>
|
||||
<div class="design-card-meta">${d.productType}${d.artifactId ? ' • ready' : ''}</div>
|
||||
<div class="design-card-actions">
|
||||
${d.artifactId ? `<a class="result-btn result-btn-primary" style="font-size:0.7rem;padding:3px 8px" href="${this.getApiBase()}/api/artifact/${d.artifactId}/file" target="_blank">Download</a>` : ''}
|
||||
${d.createdBy === getMyDid() ? `<button class="design-delete" data-delete-design="${d.id}">Delete</button>` : ''}
|
||||
</div>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
<hr class="divider">
|
||||
` : ''}
|
||||
|
||||
<div class="section-label">New Design</div>
|
||||
|
||||
<div class="products">
|
||||
${products.map((p) => `
|
||||
<div class="product ${this.selectedProduct === p.id ? 'active' : ''}" data-product="${p.id}">
|
||||
|
|
@ -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<HTMLElement>('[data-delete-design]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const id = btn.dataset.deleteDesign;
|
||||
if (id) this.deleteSharedDesign(id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private getDemoStyles(): string {
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<SwagDoc>(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<SwagDoc | null> {
|
||||
const docId = swagDocId(this.#space) as DocumentId;
|
||||
let doc = this.#documents.get<SwagDoc>(docId);
|
||||
if (!doc) {
|
||||
const binary = await this.#store.load(docId);
|
||||
doc = binary
|
||||
? this.#documents.open<SwagDoc>(docId, swagSchema, binary)
|
||||
: this.#documents.open<SwagDoc>(docId, swagSchema);
|
||||
}
|
||||
await this.#sync.subscribe([docId]);
|
||||
return doc ?? null;
|
||||
}
|
||||
|
||||
getDoc(): SwagDoc | undefined {
|
||||
return this.#documents.get<SwagDoc>(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<SwagDoc>(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<SwagDoc>(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<SwagDoc>(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<SwagDoc>(docId, `Set active design`, (d) => {
|
||||
d.activeDesignId = designId;
|
||||
});
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
await this.#sync.flush();
|
||||
this.#sync.disconnect();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, SwagDesign>;
|
||||
/** Currently active design ID for the space */
|
||||
activeDesignId: string | null;
|
||||
}
|
||||
|
||||
// ── Schema registration ──
|
||||
|
||||
export const swagSchema: DocSchema<SwagDoc> = {
|
||||
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;
|
||||
}
|
||||
Loading…
Reference in New Issue