Initial local backup snapshot
This commit is contained in:
commit
acd9e14ba5
367 changed files with 118038 additions and 0 deletions
764
shared/appearance.ts
Normal file
764
shared/appearance.ts
Normal file
|
|
@ -0,0 +1,764 @@
|
|||
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),
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue