/** * Color-scheme handlers. * * Proxy the marketplace catalogue (`${marketplace}/api/v1/color-schemes`) for * the admin browse page, and persist a site's chosen scheme as the option * `emdash:color_scheme` = { id, title, declarations }. `EmDashHead` reads that * option and injects the declarations into every page's , so applying a * scheme recolors the live site with no rebuild. */ import type { Kysely } from "kysely"; import { OptionsRepository } from "../../database/repositories/options.js"; import type { Database } from "../../database/types.js"; import type { ApiResult } from "../types.js"; /** The option key holding the applied scheme. */ export const COLOR_SCHEME_OPTION = "emdash:color_scheme"; export interface AppliedColorScheme { id: string; title: string; declarations: string; /** Kumo admin override CSS — recolors the admin panel to match. */ adminCss: string; } function base(marketplaceUrl: string | undefined): string | null { const u = (marketplaceUrl ?? "").trim().replace(/\/$/, ""); return u ? u : null; } /** GET the catalogue list from the marketplace. */ export async function fetchColorSchemes( marketplaceUrl: string | undefined, ): Promise> { const b = base(marketplaceUrl); if (!b) { return { success: false, error: { code: "MARKETPLACE_NOT_CONFIGURED", message: "Marketplace is not configured" } }; } try { const res = await fetch(`${b}/api/v1/color-schemes`); if (!res.ok) throw new Error(`marketplace ${res.status}`); return { success: true, data: await res.json() }; } catch (err) { return { success: false, error: { code: "MARKETPLACE_UNAVAILABLE", message: err instanceof Error ? err.message : "Marketplace is unavailable" } }; } } /** GET one scheme (with declarations) from the marketplace. */ export async function fetchColorScheme( marketplaceUrl: string | undefined, id: string, ): Promise> { const b = base(marketplaceUrl); if (!b) { return { success: false, error: { code: "MARKETPLACE_NOT_CONFIGURED", message: "Marketplace is not configured" } }; } try { const res = await fetch(`${b}/api/v1/color-schemes/${encodeURIComponent(id)}`); if (res.status === 404) { return { success: false, error: { code: "NOT_FOUND", message: "Color scheme not found" } }; } if (!res.ok) throw new Error(`marketplace ${res.status}`); return { success: true, data: (await res.json()) as { id: string; title: string; declarations: string; adminCss?: string } }; } catch (err) { return { success: false, error: { code: "MARKETPLACE_UNAVAILABLE", message: err instanceof Error ? err.message : "Marketplace is unavailable" } }; } } /** Read the applied scheme, or null when none is set. */ export async function getAppliedColorScheme( db: Kysely, ): Promise { const scheme = await new OptionsRepository(db).get>(COLOR_SCHEME_OPTION); if (!scheme || typeof scheme.declarations !== "string" || !scheme.declarations) return null; return { id: String(scheme.id ?? ""), title: String(scheme.title ?? ""), declarations: scheme.declarations, adminCss: typeof scheme.adminCss === "string" ? scheme.adminCss : "", }; } /** Apply (persist) a scheme fetched from the marketplace. */ export async function applyColorScheme( db: Kysely, marketplaceUrl: string | undefined, id: string, ): Promise> { const fetched = await fetchColorScheme(marketplaceUrl, id); if (!fetched.success) return fetched; const { title, declarations, adminCss } = fetched.data; if (!declarations) { return { success: false, error: { code: "INVALID_SCHEME", message: "Scheme has no token declarations" } }; } const applied: AppliedColorScheme = { id, title: title ?? id, declarations, adminCss: adminCss ?? "" }; await new OptionsRepository(db).set(COLOR_SCHEME_OPTION, applied); return { success: true, data: applied }; } /** Clear the applied scheme (revert to the theme's built-in tokens). */ export async function clearColorScheme(db: Kysely): Promise> { await new OptionsRepository(db).delete(COLOR_SCHEME_OPTION); return { success: true, data: { cleared: true } }; }