Files
moteva/frontend/src/infrastructure/brandKitRepository.ts
T
root 2df9ae6581 feat(frontend): add brand kit management UI and project binding
Add a brand kit domain model, repository, and dedicated page/route for
managing brand kits, plus a BrandKitSelector used on the home page and
canvas workspace. Home project creation and the workspace can attach a
brand kit to a project, with new i18n strings and a side-nav entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 20:59:05 +08:00

154 lines
4.6 KiB
TypeScript

import type { BrandKit } from "@/domain/brandKit";
import { isBrandKit, withBrandKitDefaults } from "@/domain/brandKit";
import { request } from "@/infrastructure/designGateway/request";
const legacyStoragePrefix = "moteva:brand-kits:v1";
const migrationPrefix = "moteva:brand-kits:server-migration:v1";
const migrations = new Map<string, Promise<void>>();
type APIBrandKit = {
id: string;
name: string;
document: string;
isDefault: boolean;
createdAt: string;
updatedAt: string;
};
type APIBrandKitListResponse = {
brandKits: APIBrandKit[];
};
type APIBrandKitResponse = {
brandKit: APIBrandKit;
};
export const brandKitRepository = {
async list(scope: string | null) {
if (!scope) return [];
await migrateLegacyKits(scope);
return listRemote();
},
async save(scope: string | null, kit: BrandKit) {
assertUserScope(scope);
const response = await request<APIBrandKitResponse>(`/api/brand-kits/${encodeURIComponent(kit.id)}`, {
method: "PUT",
body: JSON.stringify({
document: JSON.stringify(serializableBrandKit(kit)),
isDefault: kit.applyToNewProjects
})
});
return fromAPIKit(response.brandKit);
},
async remove(scope: string | null, id: string) {
assertUserScope(scope);
await request<{ id: string; deleted: boolean }>(`/api/brand-kits/${encodeURIComponent(id)}`, {
method: "DELETE"
});
return listRemote();
}
};
export function brandKitStorageScope(userId?: string) {
return userId?.trim() || null;
}
async function listRemote() {
const response = await request<APIBrandKitListResponse>("/api/brand-kits");
return response.brandKits.map(fromAPIKit).sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
}
function fromAPIKit(value: APIBrandKit): BrandKit {
const parsed: unknown = JSON.parse(value.document);
if (!isBrandKit(parsed)) throw new Error("Invalid Brand Kit document returned by the server");
return withBrandKitDefaults({
...parsed,
id: value.id,
name: value.name,
applyToNewProjects: value.isDefault,
createdAt: value.createdAt,
updatedAt: value.updatedAt
});
}
function serializableBrandKit(kit: BrandKit): BrandKit {
return {
version: 1,
id: kit.id,
name: kit.name,
brandName: kit.brandName,
tagline: kit.tagline,
summary: kit.summary,
audience: kit.audience,
positioning: kit.positioning,
personality: kit.personality,
voice: { ...kit.voice },
logos: kit.logos.map((logo) => ({ ...logo })),
coverImage: kit.coverImage ? { ...kit.coverImage } : undefined,
colorGroups: kit.colorGroups.map((group) => ({
...group,
colors: group.colors.map((color) => ({ ...color }))
})),
typography: kit.typography.map((item) => ({
...item,
fontFile: item.fontFile ? { ...item.fontFile } : undefined
})),
referenceImages: kit.referenceImages.map((image) => ({ ...image })),
visual: { ...kit.visual },
applyToNewProjects: kit.applyToNewProjects,
createdAt: kit.createdAt,
updatedAt: kit.updatedAt
};
}
function migrateLegacyKits(scope: string) {
const active = migrations.get(scope);
if (active) return active;
const migration = runLegacyMigration(scope).finally(() => migrations.delete(scope));
migrations.set(scope, migration);
return migration;
}
async function runLegacyMigration(scope: string) {
if (typeof window === "undefined") return;
const marker = migrationKey(scope);
if (window.localStorage.getItem(marker) === "done") return;
const local = readLegacyKits(scope);
if (local.length > 0) {
const remote = await listRemote();
const remoteIds = new Set(remote.map((kit) => kit.id));
for (const kit of local) {
if (!remoteIds.has(kit.id)) await brandKitRepository.save(scope, kit);
}
}
window.localStorage.removeItem(legacyStorageKey(scope));
window.localStorage.setItem(marker, "done");
}
function readLegacyKits(scope: string) {
if (typeof window === "undefined") return [];
const raw = window.localStorage.getItem(legacyStorageKey(scope));
if (!raw) return [];
try {
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter(isBrandKit).map(withBrandKitDefaults);
} catch {
return [];
}
}
function legacyStorageKey(scope: string) {
return `${legacyStoragePrefix}:${encodeURIComponent(scope)}`;
}
function migrationKey(scope: string) {
return `${migrationPrefix}:${encodeURIComponent(scope)}`;
}
function assertUserScope(scope: string | null): asserts scope is string {
if (!scope) throw new Error("Authenticated user scope is required");
}