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>
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
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");
|
||||
}
|
||||
Reference in New Issue
Block a user