764 lines
28 KiB
TypeScript
764 lines
28 KiB
TypeScript
export const appearanceThemeModes = ["light", "dark", "system"] as const;
|
||
export const appearanceFontFamilies = ["marianne", "system", "rounded", "serif"] as const;
|
||
export const appearanceCardStyles = ["soft", "outlined", "elevated"] as const;
|
||
export const appearanceTableStyles = ["comfortable", "compact", "striped"] as const;
|
||
export const appearanceDisplayDensities = ["comfortable", "compact", "detailed"] as const;
|
||
export const appearanceSidebarStyles = ["soft", "contrast", "floating"] as const;
|
||
export const appearanceButtonStyles = ["rounded", "pill", "square"] as const;
|
||
export const appearanceInputStyles = ["outline", "soft", "filled"] as const;
|
||
export const appearanceBackgroundModes = ["cover", "repeat", "fixed"] as const;
|
||
export const appearanceBackgroundPresentationModes = ["full", "edge_frame"] as const;
|
||
export const appearanceDecorationScopes = [
|
||
"all",
|
||
"home",
|
||
"public",
|
||
"public_directory",
|
||
"public_map",
|
||
"dashboard",
|
||
"dashboard_home",
|
||
"dashboard_profile",
|
||
"dashboard_documents",
|
||
"dashboard_requests",
|
||
"dashboard_new_request",
|
||
"dashboard_reservation_salle",
|
||
"dashboard_material",
|
||
"dashboard_directory",
|
||
"admin",
|
||
"admin_pilotage",
|
||
"admin_logistics",
|
||
"admin_directory",
|
||
"admin_compliance",
|
||
] as const;
|
||
export const appearanceDecorationLayers = ["background", "card"] as const;
|
||
export const appearanceCardDecorationAnchors = ["top_left", "top_right", "bottom_left", "bottom_right"] as const;
|
||
export const appearanceBackgroundPositions = [
|
||
"center",
|
||
"top",
|
||
"bottom",
|
||
"left",
|
||
"right",
|
||
"top_left",
|
||
"top_right",
|
||
"bottom_left",
|
||
"bottom_right",
|
||
] as const;
|
||
export const appearanceBackgroundTargets = [
|
||
"home",
|
||
"public",
|
||
"public_directory",
|
||
"public_map",
|
||
"dashboard",
|
||
"dashboard_home",
|
||
"dashboard_profile",
|
||
"dashboard_documents",
|
||
"dashboard_requests",
|
||
"dashboard_new_request",
|
||
"dashboard_reservation_salle",
|
||
"dashboard_material",
|
||
"dashboard_directory",
|
||
"admin",
|
||
"admin_pilotage",
|
||
"admin_logistics",
|
||
"admin_directory",
|
||
"admin_compliance",
|
||
] as const;
|
||
export const appearanceAssetFields = ["logoUrl", "faviconUrl", "heroImageUrl", "backgroundImageUrl"] as const;
|
||
|
||
export type AppearanceThemeMode = (typeof appearanceThemeModes)[number];
|
||
export type AppearanceFontFamily = (typeof appearanceFontFamilies)[number];
|
||
export type AppearanceCardStyle = (typeof appearanceCardStyles)[number];
|
||
export type AppearanceTableStyle = (typeof appearanceTableStyles)[number];
|
||
export type AppearanceDisplayDensity = (typeof appearanceDisplayDensities)[number];
|
||
export type AppearanceSidebarStyle = (typeof appearanceSidebarStyles)[number];
|
||
export type AppearanceButtonStyle = (typeof appearanceButtonStyles)[number];
|
||
export type AppearanceInputStyle = (typeof appearanceInputStyles)[number];
|
||
export type AppearanceBackgroundMode = (typeof appearanceBackgroundModes)[number];
|
||
export type AppearanceBackgroundPresentationMode = (typeof appearanceBackgroundPresentationModes)[number];
|
||
export type AppearanceDecorationScope = (typeof appearanceDecorationScopes)[number];
|
||
export type AppearanceDecorationLayer = (typeof appearanceDecorationLayers)[number];
|
||
export type AppearanceCardDecorationAnchor = (typeof appearanceCardDecorationAnchors)[number];
|
||
export type AppearanceBackgroundPosition = (typeof appearanceBackgroundPositions)[number];
|
||
export type AppearanceBackgroundTarget = (typeof appearanceBackgroundTargets)[number];
|
||
export type AppearanceAssetField = (typeof appearanceAssetFields)[number];
|
||
|
||
export interface PortalAppearanceDecoration {
|
||
id: string;
|
||
imageUrl: string;
|
||
scope: AppearanceDecorationScope;
|
||
layer: AppearanceDecorationLayer;
|
||
widthPercent: number;
|
||
topPercent: number;
|
||
leftPercent: number;
|
||
opacity: number;
|
||
blurRadius: number;
|
||
cardAnchor: AppearanceCardDecorationAnchor;
|
||
cardInsetPercent: number;
|
||
}
|
||
|
||
export interface PortalAppearance {
|
||
themeMode: AppearanceThemeMode;
|
||
activeSkinId: string;
|
||
portalTitle: string;
|
||
portalTagline: string;
|
||
primaryColor: string;
|
||
secondaryColor: string;
|
||
accentColor: string;
|
||
buttonColor: string;
|
||
alertColor: string;
|
||
fontFamily: AppearanceFontFamily;
|
||
headingFontFamily: AppearanceFontFamily;
|
||
headingScalePercent: number;
|
||
bodyScalePercent: number;
|
||
logoUrl: string;
|
||
faviconUrl: string;
|
||
heroImageUrl: string;
|
||
backgroundImageUrl: string;
|
||
backgroundEnabled: boolean;
|
||
backgroundMode: AppearanceBackgroundMode;
|
||
backgroundPresentationMode: AppearanceBackgroundPresentationMode;
|
||
backgroundOverlayOpacity: number;
|
||
backgroundBlurRadius: number;
|
||
backgroundTargets: AppearanceBackgroundTarget[];
|
||
backgroundTargetImageUrls: Partial<Record<AppearanceBackgroundTarget, string>>;
|
||
backgroundTargetOverlayOpacities: Partial<Record<AppearanceBackgroundTarget, number>>;
|
||
backgroundTargetBlurRadii: Partial<Record<AppearanceBackgroundTarget, number>>;
|
||
backgroundTargetPositions: Partial<Record<AppearanceBackgroundTarget, AppearanceBackgroundPosition>>;
|
||
backgroundTargetPresentationModes: Partial<Record<AppearanceBackgroundTarget, AppearanceBackgroundPresentationMode>>;
|
||
heroImagePositionX: number;
|
||
heroImagePositionY: number;
|
||
heroOverlayOpacity: number;
|
||
heroTitlePositionX: number;
|
||
heroTitlePositionY: number;
|
||
heroTitleWidthPercent: number;
|
||
heroTaglinePositionX: number;
|
||
heroTaglinePositionY: number;
|
||
heroTaglineWidthPercent: number;
|
||
decorativeElements: PortalAppearanceDecoration[];
|
||
cardStyle: AppearanceCardStyle;
|
||
tableStyle: AppearanceTableStyle;
|
||
displayDensity: AppearanceDisplayDensity;
|
||
sidebarStyle: AppearanceSidebarStyle;
|
||
buttonStyle: AppearanceButtonStyle;
|
||
inputStyle: AppearanceInputStyle;
|
||
}
|
||
|
||
export interface AppearanceSkinPreset {
|
||
id: string;
|
||
label: string;
|
||
description: string;
|
||
category: string;
|
||
usage: string[];
|
||
value: Partial<PortalAppearance>;
|
||
}
|
||
|
||
export const APPEARANCE_SETTING_KEY = "appearance";
|
||
|
||
export const defaultPortalAppearance: PortalAppearance = {
|
||
themeMode: "light",
|
||
activeSkinId: "guyane-institutionnelle",
|
||
portalTitle: "Portail Associations 973",
|
||
portalTagline: "Démarches, annuaire et services du tissu associatif guyanais",
|
||
primaryColor: "#2e7d32",
|
||
secondaryColor: "#1565c0",
|
||
accentColor: "#f9a825",
|
||
buttonColor: "#1565c0",
|
||
alertColor: "#dc2626",
|
||
fontFamily: "marianne",
|
||
headingFontFamily: "marianne",
|
||
headingScalePercent: 100,
|
||
bodyScalePercent: 100,
|
||
logoUrl: "",
|
||
faviconUrl: "",
|
||
heroImageUrl: "",
|
||
backgroundImageUrl: "",
|
||
backgroundEnabled: false,
|
||
backgroundMode: "cover",
|
||
backgroundPresentationMode: "full",
|
||
backgroundOverlayOpacity: 72,
|
||
backgroundBlurRadius: 0,
|
||
backgroundTargets: ["home", "public", "dashboard", "admin"],
|
||
backgroundTargetImageUrls: {},
|
||
backgroundTargetOverlayOpacities: {},
|
||
backgroundTargetBlurRadii: {},
|
||
backgroundTargetPositions: {},
|
||
backgroundTargetPresentationModes: {},
|
||
heroImagePositionX: 50,
|
||
heroImagePositionY: 50,
|
||
heroOverlayOpacity: 74,
|
||
heroTitlePositionX: 0,
|
||
heroTitlePositionY: 0,
|
||
heroTitleWidthPercent: 50,
|
||
heroTaglinePositionX: 0,
|
||
heroTaglinePositionY: 0,
|
||
heroTaglineWidthPercent: 80,
|
||
decorativeElements: [],
|
||
cardStyle: "soft",
|
||
tableStyle: "comfortable",
|
||
displayDensity: "comfortable",
|
||
sidebarStyle: "soft",
|
||
buttonStyle: "rounded",
|
||
inputStyle: "outline",
|
||
};
|
||
|
||
export const appearanceSkinPresets: AppearanceSkinPreset[] = [
|
||
{
|
||
id: "guyane-institutionnelle",
|
||
label: "Guyane institutionnelle",
|
||
description: "Palette ancrée dans le territoire, pensée pour les collectivités, services publics et fédérations.",
|
||
category: "Institutionnel",
|
||
usage: ["collectivités", "mairies", "fédérations", "services publics"],
|
||
value: {
|
||
activeSkinId: "guyane-institutionnelle",
|
||
portalTitle: "Portail Associations 973",
|
||
portalTagline: "L’espace territorial des démarches associatives en Guyane",
|
||
primaryColor: "#2e7d32",
|
||
secondaryColor: "#1565c0",
|
||
accentColor: "#f9a825",
|
||
buttonColor: "#1565c0",
|
||
alertColor: "#c62828",
|
||
fontFamily: "marianne",
|
||
headingFontFamily: "marianne",
|
||
cardStyle: "soft",
|
||
tableStyle: "comfortable",
|
||
displayDensity: "comfortable",
|
||
sidebarStyle: "soft",
|
||
buttonStyle: "rounded",
|
||
inputStyle: "outline",
|
||
backgroundEnabled: false,
|
||
backgroundMode: "cover",
|
||
backgroundPresentationMode: "full",
|
||
backgroundOverlayOpacity: 72,
|
||
backgroundBlurRadius: 0,
|
||
backgroundTargets: ["home", "public", "dashboard", "admin"],
|
||
backgroundTargetImageUrls: {},
|
||
themeMode: "light",
|
||
},
|
||
},
|
||
{
|
||
id: "centre-spatial-guyanais",
|
||
label: "Centre spatial guyanais",
|
||
description: "Ambiance technologique et institutionnelle, inspirée du spatial, de l’innovation et des grands projets.",
|
||
category: "Spatial",
|
||
usage: ["innovation", "recherche", "éducation", "projets scientifiques"],
|
||
value: {
|
||
activeSkinId: "centre-spatial-guyanais",
|
||
portalTitle: "Portail Associations 973",
|
||
portalTagline: "Innovation, projets et coopérations du territoire guyanais",
|
||
primaryColor: "#0b3d91",
|
||
secondaryColor: "#1f5fbf",
|
||
accentColor: "#f6b317",
|
||
buttonColor: "#155eef",
|
||
alertColor: "#d92d20",
|
||
fontFamily: "system",
|
||
headingFontFamily: "rounded",
|
||
cardStyle: "elevated",
|
||
tableStyle: "striped",
|
||
displayDensity: "compact",
|
||
sidebarStyle: "contrast",
|
||
buttonStyle: "pill",
|
||
inputStyle: "soft",
|
||
backgroundEnabled: false,
|
||
backgroundMode: "cover",
|
||
backgroundPresentationMode: "full",
|
||
backgroundOverlayOpacity: 68,
|
||
backgroundBlurRadius: 0,
|
||
backgroundTargets: ["home", "public", "dashboard", "admin"],
|
||
backgroundTargetImageUrls: {},
|
||
themeMode: "light",
|
||
},
|
||
},
|
||
{
|
||
id: "nature-amazonienne",
|
||
label: "Nature amazonienne",
|
||
description: "Univers plus organique, centré sur la biodiversité, l’environnement et les initiatives de terrain.",
|
||
category: "Territoire",
|
||
usage: ["environnement", "tourisme", "biodiversité", "éducation populaire"],
|
||
value: {
|
||
activeSkinId: "nature-amazonienne",
|
||
portalTitle: "Portail Associations 973",
|
||
portalTagline: "Les initiatives guyanaises entre nature, territoire et engagement",
|
||
primaryColor: "#1b5e20",
|
||
secondaryColor: "#4caf50",
|
||
accentColor: "#0277bd",
|
||
buttonColor: "#2e7d32",
|
||
alertColor: "#d84315",
|
||
fontFamily: "serif",
|
||
headingFontFamily: "serif",
|
||
cardStyle: "outlined",
|
||
tableStyle: "comfortable",
|
||
displayDensity: "detailed",
|
||
sidebarStyle: "floating",
|
||
buttonStyle: "rounded",
|
||
inputStyle: "filled",
|
||
backgroundEnabled: false,
|
||
backgroundMode: "cover",
|
||
backgroundPresentationMode: "full",
|
||
backgroundOverlayOpacity: 70,
|
||
backgroundBlurRadius: 0,
|
||
backgroundTargets: ["home", "public", "dashboard", "admin"],
|
||
backgroundTargetImageUrls: {},
|
||
themeMode: "light",
|
||
},
|
||
},
|
||
{
|
||
id: "republicain",
|
||
label: "Républicain",
|
||
description: "Lecture claire, signal institutionnel fort, pour les espaces administratifs et les usages plus officiels.",
|
||
category: "Administration",
|
||
usage: ["préfecture", "administration", "services publics", "collectivités"],
|
||
value: {
|
||
activeSkinId: "republicain",
|
||
portalTitle: "Portail Associations 973",
|
||
portalTagline: "Un portail administratif lisible, fiable et immédiatement identifiable",
|
||
primaryColor: "#0055a4",
|
||
secondaryColor: "#ffffff",
|
||
accentColor: "#ef4135",
|
||
buttonColor: "#0055a4",
|
||
alertColor: "#b42318",
|
||
fontFamily: "marianne",
|
||
headingFontFamily: "marianne",
|
||
cardStyle: "outlined",
|
||
tableStyle: "compact",
|
||
displayDensity: "compact",
|
||
sidebarStyle: "contrast",
|
||
buttonStyle: "square",
|
||
inputStyle: "outline",
|
||
backgroundEnabled: false,
|
||
backgroundMode: "cover",
|
||
backgroundPresentationMode: "full",
|
||
backgroundOverlayOpacity: 78,
|
||
backgroundBlurRadius: 0,
|
||
backgroundTargets: ["home", "public", "dashboard", "admin"],
|
||
backgroundTargetImageUrls: {},
|
||
themeMode: "light",
|
||
},
|
||
},
|
||
{
|
||
id: "institution-immersif",
|
||
label: "Institution immersif",
|
||
description: "Preset prêt à l’emploi pour un fond premium lisible, avec centre neutre et bords illustrés renforcés.",
|
||
category: "Immersif",
|
||
usage: ["dashboard", "portail institutionnel", "parcours connectés", "habillage premium"],
|
||
value: {
|
||
activeSkinId: "institution-immersif",
|
||
portalTitle: "Portail Associations 973",
|
||
portalTagline: "Un portail territorial clair, immersif et immédiatement lisible",
|
||
primaryColor: "#2e7d32",
|
||
secondaryColor: "#1565c0",
|
||
accentColor: "#f9a825",
|
||
buttonColor: "#1565c0",
|
||
alertColor: "#c62828",
|
||
fontFamily: "marianne",
|
||
headingFontFamily: "marianne",
|
||
cardStyle: "soft",
|
||
tableStyle: "comfortable",
|
||
displayDensity: "compact",
|
||
sidebarStyle: "soft",
|
||
buttonStyle: "rounded",
|
||
inputStyle: "outline",
|
||
backgroundEnabled: true,
|
||
backgroundMode: "cover",
|
||
backgroundPresentationMode: "edge_frame",
|
||
backgroundOverlayOpacity: 62,
|
||
backgroundBlurRadius: 0,
|
||
backgroundTargets: ["home", "public", "dashboard", "admin"],
|
||
backgroundTargetImageUrls: {},
|
||
backgroundTargetOverlayOpacities: {
|
||
home: 58,
|
||
public: 64,
|
||
dashboard: 60,
|
||
admin: 68,
|
||
},
|
||
backgroundTargetPositions: {
|
||
home: "center",
|
||
public: "center",
|
||
dashboard: "right",
|
||
admin: "right",
|
||
},
|
||
backgroundTargetPresentationModes: {
|
||
home: "edge_frame",
|
||
public: "edge_frame",
|
||
dashboard: "edge_frame",
|
||
admin: "edge_frame",
|
||
},
|
||
themeMode: "light",
|
||
},
|
||
},
|
||
{
|
||
id: "parcours-guide-immersif",
|
||
label: "Parcours guidé immersif",
|
||
description: "Met en avant les parcours, les formulaires et les cartes avec un habillage plus narratif et très lisible.",
|
||
category: "Immersif",
|
||
usage: ["nouvelle demande", "réservation", "matériel", "parcours guidés"],
|
||
value: {
|
||
activeSkinId: "parcours-guide-immersif",
|
||
portalTitle: "Portail Associations 973",
|
||
portalTagline: "Des démarches plus claires, étape par étape",
|
||
primaryColor: "#2f9e44",
|
||
secondaryColor: "#1d4ed8",
|
||
accentColor: "#f6c453",
|
||
buttonColor: "#2563eb",
|
||
alertColor: "#c2410c",
|
||
fontFamily: "marianne",
|
||
headingFontFamily: "rounded",
|
||
cardStyle: "soft",
|
||
tableStyle: "comfortable",
|
||
displayDensity: "compact",
|
||
sidebarStyle: "floating",
|
||
buttonStyle: "pill",
|
||
inputStyle: "soft",
|
||
backgroundEnabled: true,
|
||
backgroundMode: "cover",
|
||
backgroundPresentationMode: "edge_frame",
|
||
backgroundOverlayOpacity: 56,
|
||
backgroundBlurRadius: 1,
|
||
backgroundTargets: [
|
||
"dashboard",
|
||
"dashboard_home",
|
||
"dashboard_new_request",
|
||
"dashboard_reservation_salle",
|
||
"dashboard_material",
|
||
],
|
||
backgroundTargetImageUrls: {},
|
||
backgroundTargetOverlayOpacities: {
|
||
dashboard: 58,
|
||
dashboard_home: 54,
|
||
dashboard_new_request: 52,
|
||
dashboard_reservation_salle: 56,
|
||
dashboard_material: 56,
|
||
},
|
||
backgroundTargetPositions: {
|
||
dashboard: "right",
|
||
dashboard_home: "right",
|
||
dashboard_new_request: "right",
|
||
dashboard_reservation_salle: "bottom_right",
|
||
dashboard_material: "bottom_left",
|
||
},
|
||
backgroundTargetPresentationModes: {
|
||
dashboard: "edge_frame",
|
||
dashboard_home: "edge_frame",
|
||
dashboard_new_request: "edge_frame",
|
||
dashboard_reservation_salle: "edge_frame",
|
||
dashboard_material: "edge_frame",
|
||
},
|
||
themeMode: "light",
|
||
},
|
||
},
|
||
{
|
||
id: "pilotage-territorial",
|
||
label: "Pilotage territorial",
|
||
description: "Preset plus sobre et plus dense, pensé pour le bordereau, la logistique et les référentiels admin.",
|
||
category: "Immersif",
|
||
usage: ["bordereau", "logistique", "référentiels", "pilotage admin"],
|
||
value: {
|
||
activeSkinId: "pilotage-territorial",
|
||
portalTitle: "Portail Associations 973",
|
||
portalTagline: "Référentiels, logistique et pilotage du territoire",
|
||
primaryColor: "#256f5c",
|
||
secondaryColor: "#0f4c81",
|
||
accentColor: "#d97706",
|
||
buttonColor: "#0f4c81",
|
||
alertColor: "#b91c1c",
|
||
fontFamily: "marianne",
|
||
headingFontFamily: "marianne",
|
||
cardStyle: "outlined",
|
||
tableStyle: "compact",
|
||
displayDensity: "compact",
|
||
sidebarStyle: "soft",
|
||
buttonStyle: "rounded",
|
||
inputStyle: "outline",
|
||
backgroundEnabled: true,
|
||
backgroundMode: "cover",
|
||
backgroundPresentationMode: "edge_frame",
|
||
backgroundOverlayOpacity: 68,
|
||
backgroundBlurRadius: 0,
|
||
backgroundTargets: ["admin", "admin_pilotage", "admin_logistics", "admin_directory", "admin_compliance"],
|
||
backgroundTargetImageUrls: {},
|
||
backgroundTargetOverlayOpacities: {
|
||
admin: 68,
|
||
admin_pilotage: 70,
|
||
admin_logistics: 66,
|
||
admin_directory: 64,
|
||
admin_compliance: 72,
|
||
},
|
||
backgroundTargetPositions: {
|
||
admin: "right",
|
||
admin_pilotage: "center",
|
||
admin_logistics: "bottom_right",
|
||
admin_directory: "left",
|
||
admin_compliance: "top_right",
|
||
},
|
||
backgroundTargetPresentationModes: {
|
||
admin: "edge_frame",
|
||
admin_pilotage: "edge_frame",
|
||
admin_logistics: "edge_frame",
|
||
admin_directory: "edge_frame",
|
||
admin_compliance: "edge_frame",
|
||
},
|
||
themeMode: "light",
|
||
},
|
||
},
|
||
];
|
||
|
||
const HEX_COLOR_REGEX = /^#(?:[0-9a-fA-F]{3}){1,2}$/;
|
||
|
||
function normalizeHexColor(value: string | undefined, fallback: string) {
|
||
const trimmed = value?.trim();
|
||
if (!trimmed || !HEX_COLOR_REGEX.test(trimmed)) {
|
||
return fallback;
|
||
}
|
||
|
||
if (trimmed.length === 4) {
|
||
return `#${trimmed[1]}${trimmed[1]}${trimmed[2]}${trimmed[2]}${trimmed[3]}${trimmed[3]}`.toLowerCase();
|
||
}
|
||
|
||
return trimmed.toLowerCase();
|
||
}
|
||
|
||
function normalizeUrl(value: string | undefined) {
|
||
return value?.trim() || "";
|
||
}
|
||
|
||
function normalizeText(value: string | undefined, fallback: string, maxLength: number) {
|
||
const trimmed = value?.trim();
|
||
if (!trimmed) {
|
||
return fallback;
|
||
}
|
||
return trimmed.slice(0, maxLength);
|
||
}
|
||
|
||
function normalizePercent(value: number | undefined, fallback: number, min = 0, max = 100) {
|
||
if (typeof value !== "number" || Number.isNaN(value)) {
|
||
return fallback;
|
||
}
|
||
|
||
return Math.min(max, Math.max(min, Math.round(value)));
|
||
}
|
||
|
||
function normalizeOption<T extends readonly string[]>(value: string | undefined, allowed: T, fallback: T[number]) {
|
||
if (value && allowed.includes(value)) {
|
||
return value as T[number];
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
function normalizeOptionsArray<T extends readonly string[]>(value: string[] | undefined, allowed: T, fallback: readonly T[number][]) {
|
||
if (!Array.isArray(value)) {
|
||
return [...fallback] as T[number][];
|
||
}
|
||
|
||
const filtered = value.filter((entry): entry is T[number] => allowed.includes(entry as T[number]));
|
||
return filtered.length ? Array.from(new Set(filtered)) : [...fallback] as T[number][];
|
||
}
|
||
|
||
function normalizeTargetImageUrls(
|
||
value: Partial<Record<string, string>> | undefined,
|
||
allowed: readonly AppearanceBackgroundTarget[],
|
||
) {
|
||
const next: Partial<Record<AppearanceBackgroundTarget, string>> = {};
|
||
if (!value || typeof value !== "object") {
|
||
return next;
|
||
}
|
||
|
||
for (const target of allowed) {
|
||
const normalized = normalizeUrl(value[target]);
|
||
if (normalized) {
|
||
next[target] = normalized;
|
||
}
|
||
}
|
||
|
||
return next;
|
||
}
|
||
|
||
function normalizeTargetOverlayOpacities(
|
||
value: Partial<Record<string, number>> | undefined,
|
||
allowed: readonly AppearanceBackgroundTarget[],
|
||
) {
|
||
const next: Partial<Record<AppearanceBackgroundTarget, number>> = {};
|
||
if (!value || typeof value !== "object") {
|
||
return next;
|
||
}
|
||
|
||
for (const target of allowed) {
|
||
const raw = value[target];
|
||
if (typeof raw === "number" && !Number.isNaN(raw)) {
|
||
next[target] = Math.min(100, Math.max(0, Math.round(raw)));
|
||
}
|
||
}
|
||
|
||
return next;
|
||
}
|
||
|
||
function normalizeTargetBlurRadii(
|
||
value: Partial<Record<string, number>> | undefined,
|
||
allowed: readonly AppearanceBackgroundTarget[],
|
||
) {
|
||
const next: Partial<Record<AppearanceBackgroundTarget, number>> = {};
|
||
if (!value || typeof value !== "object") {
|
||
return next;
|
||
}
|
||
|
||
for (const target of allowed) {
|
||
const raw = value[target];
|
||
if (typeof raw === "number" && !Number.isNaN(raw)) {
|
||
next[target] = Math.min(24, Math.max(0, Math.round(raw)));
|
||
}
|
||
}
|
||
|
||
return next;
|
||
}
|
||
|
||
function normalizeTargetPositions(
|
||
value: Partial<Record<string, string>> | undefined,
|
||
allowed: readonly AppearanceBackgroundTarget[],
|
||
) {
|
||
const next: Partial<Record<AppearanceBackgroundTarget, AppearanceBackgroundPosition>> = {};
|
||
if (!value || typeof value !== "object") {
|
||
return next;
|
||
}
|
||
|
||
for (const target of allowed) {
|
||
const raw = value[target];
|
||
if (raw && appearanceBackgroundPositions.includes(raw as AppearanceBackgroundPosition)) {
|
||
next[target] = raw as AppearanceBackgroundPosition;
|
||
}
|
||
}
|
||
|
||
return next;
|
||
}
|
||
|
||
function normalizeTargetPresentationModes(
|
||
value: Partial<Record<string, string>> | undefined,
|
||
allowed: readonly AppearanceBackgroundTarget[],
|
||
) {
|
||
const next: Partial<Record<AppearanceBackgroundTarget, AppearanceBackgroundPresentationMode>> = {};
|
||
if (!value || typeof value !== "object") {
|
||
return next;
|
||
}
|
||
|
||
for (const target of allowed) {
|
||
const raw = value[target];
|
||
if (raw && appearanceBackgroundPresentationModes.includes(raw as AppearanceBackgroundPresentationMode)) {
|
||
next[target] = raw as AppearanceBackgroundPresentationMode;
|
||
}
|
||
}
|
||
|
||
return next;
|
||
}
|
||
|
||
function normalizeDecorativeElements(value: unknown): PortalAppearanceDecoration[] {
|
||
if (!Array.isArray(value)) {
|
||
return [];
|
||
}
|
||
|
||
return value
|
||
.map((entry, index) => {
|
||
const raw = entry && typeof entry === "object" ? entry as Partial<PortalAppearanceDecoration> : {};
|
||
const imageUrl = normalizeUrl(raw.imageUrl);
|
||
if (!imageUrl) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
id: normalizeText(raw.id, `decor-${index + 1}`, 80),
|
||
imageUrl,
|
||
scope: normalizeOption(raw.scope, appearanceDecorationScopes, "all"),
|
||
layer: normalizeOption(raw.layer, appearanceDecorationLayers, "background"),
|
||
widthPercent: normalizePercent(raw.widthPercent, 18, 6, 38),
|
||
topPercent: normalizePercent(raw.topPercent, 20, 0, 100),
|
||
leftPercent: normalizePercent(raw.leftPercent, 10, 0, 100),
|
||
opacity: normalizePercent(raw.opacity, 12, 4, 28),
|
||
blurRadius: normalizePercent(raw.blurRadius, 2, 0, 12),
|
||
cardAnchor: normalizeOption(raw.cardAnchor, appearanceCardDecorationAnchors, "top_right"),
|
||
cardInsetPercent: normalizePercent(raw.cardInsetPercent, 6, 0, 20),
|
||
} satisfies PortalAppearanceDecoration;
|
||
})
|
||
.filter((entry): entry is PortalAppearanceDecoration => Boolean(entry))
|
||
.slice(0, 6);
|
||
}
|
||
|
||
export function getAppearanceSkinPreset(presetId: string | undefined) {
|
||
return appearanceSkinPresets.find((preset) => preset.id === presetId) || null;
|
||
}
|
||
|
||
export function sanitizePortalAppearance(value?: Partial<PortalAppearance> | null): PortalAppearance {
|
||
const preset = getAppearanceSkinPreset(value?.activeSkinId) || getAppearanceSkinPreset(defaultPortalAppearance.activeSkinId);
|
||
const presetValue = preset?.value || {};
|
||
|
||
const base: PortalAppearance = {
|
||
...defaultPortalAppearance,
|
||
...presetValue,
|
||
};
|
||
const normalizedBackgroundImageUrl = normalizeUrl(value?.backgroundImageUrl);
|
||
const normalizedBackgroundTargetImageUrls = normalizeTargetImageUrls(value?.backgroundTargetImageUrls, appearanceBackgroundTargets);
|
||
const normalizedBackgroundTargetOverlayOpacities = normalizeTargetOverlayOpacities(
|
||
value?.backgroundTargetOverlayOpacities,
|
||
appearanceBackgroundTargets
|
||
);
|
||
const normalizedBackgroundTargetBlurRadii = normalizeTargetBlurRadii(
|
||
value?.backgroundTargetBlurRadii,
|
||
appearanceBackgroundTargets
|
||
);
|
||
const normalizedBackgroundTargetPositions = normalizeTargetPositions(
|
||
value?.backgroundTargetPositions,
|
||
appearanceBackgroundTargets
|
||
);
|
||
const normalizedBackgroundTargetPresentationModes = normalizeTargetPresentationModes(
|
||
value?.backgroundTargetPresentationModes,
|
||
appearanceBackgroundTargets
|
||
);
|
||
const normalizedDecorativeElements = normalizeDecorativeElements(value?.decorativeElements);
|
||
const hasAnyBackgroundAsset =
|
||
Boolean(normalizedBackgroundImageUrl) || Object.values(normalizedBackgroundTargetImageUrls).some(Boolean);
|
||
|
||
return {
|
||
themeMode: normalizeOption(value?.themeMode, appearanceThemeModes, base.themeMode),
|
||
activeSkinId: normalizeText(value?.activeSkinId, base.activeSkinId, 80),
|
||
portalTitle: normalizeText(value?.portalTitle, base.portalTitle, 120),
|
||
portalTagline: normalizeText(value?.portalTagline, base.portalTagline, 220),
|
||
primaryColor: normalizeHexColor(value?.primaryColor, base.primaryColor),
|
||
secondaryColor: normalizeHexColor(value?.secondaryColor, base.secondaryColor),
|
||
accentColor: normalizeHexColor(value?.accentColor, base.accentColor),
|
||
buttonColor: normalizeHexColor(value?.buttonColor, base.buttonColor),
|
||
alertColor: normalizeHexColor(value?.alertColor, base.alertColor),
|
||
fontFamily: normalizeOption(value?.fontFamily, appearanceFontFamilies, base.fontFamily),
|
||
headingFontFamily: normalizeOption(value?.headingFontFamily, appearanceFontFamilies, base.headingFontFamily),
|
||
headingScalePercent: normalizePercent(value?.headingScalePercent, base.headingScalePercent, 85, 130),
|
||
bodyScalePercent: normalizePercent(value?.bodyScalePercent, base.bodyScalePercent, 90, 115),
|
||
logoUrl: normalizeUrl(value?.logoUrl),
|
||
faviconUrl: normalizeUrl(value?.faviconUrl),
|
||
heroImageUrl: normalizeUrl(value?.heroImageUrl),
|
||
backgroundImageUrl: normalizedBackgroundImageUrl,
|
||
backgroundEnabled: Boolean(value?.backgroundEnabled) && hasAnyBackgroundAsset,
|
||
backgroundMode: normalizeOption(value?.backgroundMode, appearanceBackgroundModes, base.backgroundMode),
|
||
backgroundPresentationMode: normalizeOption(
|
||
value?.backgroundPresentationMode,
|
||
appearanceBackgroundPresentationModes,
|
||
base.backgroundPresentationMode
|
||
),
|
||
backgroundOverlayOpacity: normalizePercent(value?.backgroundOverlayOpacity, base.backgroundOverlayOpacity, 0, 100),
|
||
backgroundBlurRadius: normalizePercent(value?.backgroundBlurRadius, base.backgroundBlurRadius, 0, 24),
|
||
backgroundTargets: normalizeOptionsArray(value?.backgroundTargets, appearanceBackgroundTargets, base.backgroundTargets),
|
||
backgroundTargetImageUrls: normalizedBackgroundTargetImageUrls,
|
||
backgroundTargetOverlayOpacities: normalizedBackgroundTargetOverlayOpacities,
|
||
backgroundTargetBlurRadii: normalizedBackgroundTargetBlurRadii,
|
||
backgroundTargetPositions: normalizedBackgroundTargetPositions,
|
||
backgroundTargetPresentationModes: normalizedBackgroundTargetPresentationModes,
|
||
heroImagePositionX: normalizePercent(value?.heroImagePositionX, base.heroImagePositionX),
|
||
heroImagePositionY: normalizePercent(value?.heroImagePositionY, base.heroImagePositionY),
|
||
heroOverlayOpacity: normalizePercent(value?.heroOverlayOpacity, base.heroOverlayOpacity),
|
||
heroTitlePositionX: normalizePercent(value?.heroTitlePositionX, base.heroTitlePositionX, -50, 50),
|
||
heroTitlePositionY: normalizePercent(value?.heroTitlePositionY, base.heroTitlePositionY, -50, 50),
|
||
heroTitleWidthPercent: normalizePercent(value?.heroTitleWidthPercent, base.heroTitleWidthPercent, 30, 100),
|
||
heroTaglinePositionX: normalizePercent(value?.heroTaglinePositionX, base.heroTaglinePositionX, -50, 50),
|
||
heroTaglinePositionY: normalizePercent(value?.heroTaglinePositionY, base.heroTaglinePositionY, -50, 50),
|
||
heroTaglineWidthPercent: normalizePercent(value?.heroTaglineWidthPercent, base.heroTaglineWidthPercent, 30, 100),
|
||
decorativeElements: normalizedDecorativeElements,
|
||
cardStyle: normalizeOption(value?.cardStyle, appearanceCardStyles, base.cardStyle),
|
||
tableStyle: normalizeOption(value?.tableStyle, appearanceTableStyles, base.tableStyle),
|
||
displayDensity: normalizeOption(value?.displayDensity, appearanceDisplayDensities, base.displayDensity),
|
||
sidebarStyle: normalizeOption(value?.sidebarStyle, appearanceSidebarStyles, base.sidebarStyle),
|
||
buttonStyle: normalizeOption(value?.buttonStyle, appearanceButtonStyles, base.buttonStyle),
|
||
inputStyle: normalizeOption(value?.inputStyle, appearanceInputStyles, base.inputStyle),
|
||
};
|
||
}
|