Merge branch 'dev'
This commit is contained in:
commit
21884fb71b
|
|
@ -148,6 +148,23 @@ function posterMockupSvg(): string {
|
||||||
// --- Component ---
|
// --- Component ---
|
||||||
|
|
||||||
import { TourEngine } from "../../../shared/tour-engine";
|
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 {
|
class FolkSwagDesigner extends HTMLElement {
|
||||||
private shadow: ShadowRoot;
|
private shadow: ShadowRoot;
|
||||||
|
|
@ -164,6 +181,11 @@ class FolkSwagDesigner extends HTMLElement {
|
||||||
private demoStep: 1 | 2 | 3 | 4 = 1;
|
private demoStep: 1 | 2 | 3 | 4 = 1;
|
||||||
private progressStep = 0;
|
private progressStep = 0;
|
||||||
private usedSampleDesign = false;
|
private usedSampleDesign = false;
|
||||||
|
/* Multiplayer state */
|
||||||
|
private lfClient: SwagLocalFirstClient | null = null;
|
||||||
|
private _lfcUnsub: (() => void) | null = null;
|
||||||
|
private sharedDesigns: SwagDesign[] = [];
|
||||||
|
|
||||||
private _tour!: TourEngine;
|
private _tour!: TourEngine;
|
||||||
private static readonly TOUR_STEPS = [
|
private static readonly TOUR_STEPS = [
|
||||||
{ target: '.product', title: "Choose Product", message: "Select a product type — tee, sticker, poster, or hoodie.", advanceOnClick: true },
|
{ 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.demoStep = 1;
|
||||||
this.render();
|
this.render();
|
||||||
} else {
|
} else {
|
||||||
|
this.initMultiplayer();
|
||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
if (!localStorage.getItem("rswag_tour_done")) {
|
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 {
|
private getApiBase(): string {
|
||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
const match = path.match(/^(\/[^/]+)?\/rswag/);
|
const match = path.match(/^(\/[^/]+)?\/rswag/);
|
||||||
|
|
@ -338,6 +413,8 @@ class FolkSwagDesigner extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.artifact = await res.json();
|
this.artifact = await res.json();
|
||||||
|
// Sync design metadata to other space members
|
||||||
|
if (this.artifact?.id) this.saveDesignToSync(this.artifact.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.error = e instanceof Error ? e.message : "Generation failed";
|
this.error = e instanceof Error ? e.message : "Generation failed";
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -601,9 +678,27 @@ class FolkSwagDesigner extends HTMLElement {
|
||||||
{ id: "hoodie", name: "Hoodie", icon: "🧥", desc: '14x16" DTG print' },
|
{ 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 = `
|
this.shadow.innerHTML = `
|
||||||
<style>
|
<style>
|
||||||
:host { display: block; padding: 1.5rem; max-width: 900px; margin: 0 auto; }
|
: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; }
|
.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 { 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); }
|
.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; }
|
.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 { 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); }
|
.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:hover { background: #4338ca; }
|
||||||
.generate-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
.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; }
|
.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; }
|
input[type="file"] { display: none; }
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.products { grid-template-columns: repeat(2, 1fr); }
|
.products { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.designs-grid { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); }
|
||||||
}
|
}
|
||||||
</style>
|
</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">
|
<div class="products">
|
||||||
${products.map((p) => `
|
${products.map((p) => `
|
||||||
<div class="product ${this.selectedProduct === p.id ? 'active' : ''}" data-product="${p.id}">
|
<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", () => {
|
this.shadow.querySelector('[data-action="copy-json"]')?.addEventListener("click", () => {
|
||||||
navigator.clipboard.writeText(JSON.stringify(this.artifact, null, 2));
|
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 {
|
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