2df9ae6581
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>
154 lines
4.6 KiB
TypeScript
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");
|
|
}
|