/**
* 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 } };
}