diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 82af8248f..085dd314a 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -294,10 +294,58 @@ model Session { // Setup +type BrandingText { + en String? + fr String? +} + +type ResourceLink { + href String + label BrandingText? +} + +type BrandingConfig { + boldDetails Boolean? + boldName Boolean? + boldResourceLinks Boolean? + boldTagline Boolean? + customLogoHeight Int? + customLogoSrc String? + customLogoUrl String? + customLogoWidth Int? + customPrimaryColor String? + customSecondaryColor String? + detailsFontSize Int? + enableBranding Boolean? + instanceDetails BrandingText? + instanceName BrandingText? + instanceTagline BrandingText? + loginTheme String? + logoAlignment String? + logoSize String? + logoSource String? + nameAlignment String? + nameFontSize Int? + panelTextColor String? + resourceLinks ResourceLink[] + resourceLinksFontSize Int? + rightPanelPrimaryColor String? + rightPanelSecondaryColor String? + rightPanelTheme String? + sectionsOrder String[] + showDetails Boolean? + showFooterLinks Boolean? + showLogo Boolean? + showResourceLinks Boolean? + showTagline Boolean? + taglineFontSize Int? +} + model SetupState { - createdAt DateTime @default(now()) @db.Date - updatedAt DateTime @updatedAt @db.Date - id String @id @default(auto()) @map("_id") @db.ObjectId + createdAt DateTime @default(now()) @db.Date + updatedAt DateTime @updatedAt @db.Date + id String @id @default(auto()) @map("_id") @db.ObjectId + branding BrandingConfig? isDemo Boolean isExperimentalFeaturesEnabled Boolean? isSetup Boolean diff --git a/apps/api/src/setup/dto/update-setup-state.dto.ts b/apps/api/src/setup/dto/update-setup-state.dto.ts index 77407acb5..233e89429 100644 --- a/apps/api/src/setup/dto/update-setup-state.dto.ts +++ b/apps/api/src/setup/dto/update-setup-state.dto.ts @@ -1,10 +1,13 @@ import { ValidationSchema } from '@douglasneuroinformatics/libnest'; import { ApiProperty } from '@nestjs/swagger'; import { $UpdateSetupStateData } from '@opendatacapture/schemas/setup'; -import type { UpdateSetupStateData } from '@opendatacapture/schemas/setup'; +import type { BrandingConfig, UpdateSetupStateData } from '@opendatacapture/schemas/setup'; @ValidationSchema($UpdateSetupStateData) export class UpdateSetupStateDto implements UpdateSetupStateData { - @ApiProperty() + @ApiProperty({ required: false }) + branding?: BrandingConfig | null; + + @ApiProperty({ required: false }) isExperimentalFeaturesEnabled?: boolean; } diff --git a/apps/api/src/setup/setup.service.ts b/apps/api/src/setup/setup.service.ts index ac8cb7a0e..f56e324b1 100644 --- a/apps/api/src/setup/setup.service.ts +++ b/apps/api/src/setup/setup.service.ts @@ -7,6 +7,7 @@ import { InternalServerErrorException, ServiceUnavailableException } from '@nestjs/common'; +import { $BrandingConfig } from '@opendatacapture/schemas/setup'; import type { CreateAdminData, InitAppOptions, SetupState, UpdateSetupStateData } from '@opendatacapture/schemas/setup'; import type { RuntimePrismaClient } from '@/core/prisma'; @@ -37,7 +38,13 @@ export class SetupService { async getState() { const savedOptions = await this.getSavedOptions(); + // The stored value is validated against the schema so that scalar columns + // (e.g. `loginTheme`) are narrowed to their expected literal union types. + // Note: unknown keys are stripped here, so a stale dev server running an + // older $BrandingConfig will silently drop newer branding fields on read. + const branding = $BrandingConfig.nullable().safeParse(savedOptions?.branding ?? null); return { + branding: branding.success ? branding.data : null, isDemo: Boolean(savedOptions?.isDemo), isExperimentalFeaturesEnabled: Boolean(savedOptions?.isExperimentalFeaturesEnabled), isGatewayEnabled: this.configService.get('GATEWAY_ENABLED'), @@ -67,17 +74,22 @@ export class SetupService { return { success: true }; } - async updateState(data: UpdateSetupStateData): Promise> { + async updateState({ branding, ...rest }: UpdateSetupStateData): Promise> { const setupState = await this.getSavedOptions(); if (!setupState?.isSetup) { throw new ServiceUnavailableException('Cannot update state before setup'); } - return this.setupStateModel.update({ - data, + const normalizedBranding = branding ? { resourceLinks: [], sectionsOrder: [], ...branding } : branding; + await this.setupStateModel.update({ + data: { + ...rest, + ...(branding !== undefined ? { branding: { set: normalizedBranding ?? null } } : {}) + }, where: { id: setupState.id } }); + return this.getState(); } private async dropDatabase(): Promise { diff --git a/apps/web/src/components/LoginBranding/LoginBrandingPanel.stories.tsx b/apps/web/src/components/LoginBranding/LoginBrandingPanel.stories.tsx new file mode 100644 index 000000000..bac74a9b3 --- /dev/null +++ b/apps/web/src/components/LoginBranding/LoginBrandingPanel.stories.tsx @@ -0,0 +1,59 @@ +import type { BrandingConfig } from '@opendatacapture/schemas/setup'; +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { LoginBrandingPanel } from './LoginBrandingPanel'; + +type Story = StoryObj; + +const baseBranding: BrandingConfig = { + instanceName: { en: 'Open Data Capture', fr: 'Open Data Capture' }, + instanceTagline: { + en: 'A platform for clinical and research data collection.', + fr: 'Une plateforme pour la collecte de données cliniques et de recherche.' + }, + loginTheme: 'ocean' +}; + +export const Default: Story = { + args: { + branding: baseBranding, + className: 'h-screen w-screen' + } +}; + +export const Preview: Story = { + args: { + branding: baseBranding, + className: 'h-96 w-[36rem]', + preview: true + } +}; + +export const WithResources: Story = { + args: { + branding: { + ...baseBranding, + loginTheme: 'midnight', + resourceLinks: [ + { href: 'https://example.org/handbook', label: { en: 'Handbook', fr: 'Manuel' } }, + { href: 'https://example.org/contact', label: { en: 'Contact', fr: 'Contact' } } + ], + showResourceLinks: true + }, + className: 'h-screen w-screen' + } +}; + +export const CustomGradient: Story = { + args: { + branding: { + ...baseBranding, + customPrimaryColor: '#0ea5e9', + customSecondaryColor: '#7c3aed', + loginTheme: 'custom' + }, + className: 'h-screen w-screen' + } +}; + +export default { component: LoginBrandingPanel } as Meta; diff --git a/apps/web/src/components/LoginBranding/LoginBrandingPanel.tsx b/apps/web/src/components/LoginBranding/LoginBrandingPanel.tsx new file mode 100644 index 000000000..f0443f81e --- /dev/null +++ b/apps/web/src/components/LoginBranding/LoginBrandingPanel.tsx @@ -0,0 +1,378 @@ +import type React from 'react'; +import { useEffect, useMemo, useState } from 'react'; + +import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { cn } from '@douglasneuroinformatics/libui/utils'; +import { Logo } from '@opendatacapture/react-core'; +import type { + BrandingConfig, + LogoAlignment, + LogoSize, + PanelSection, + ResourceLink +} from '@opendatacapture/schemas/setup'; +import { BookOpenIcon, GithubIcon, LinkIcon } from 'lucide-react'; + +import { config } from '@/config'; +import { getLoginGradient } from '@/utils/branding'; + +const CURRENT_YEAR = new Date().getFullYear(); + +const PRESET_LOGO_HEIGHT_CLASS: { [K in Exclude]: { main: string; preview: string } } = { + large: { main: 'h-28', preview: 'h-14' }, + medium: { main: 'h-20', preview: 'h-10' }, + small: { main: 'h-12', preview: 'h-8' }, + xlarge: { main: 'h-44', preview: 'h-20' } +}; + +const LOGO_ALIGNMENT_CLASS: { [K in LogoAlignment]: string } = { + center: 'justify-center', + left: 'justify-start', + right: 'justify-end' +}; + +const TEXT_ALIGNMENT_CLASS: { [K in LogoAlignment]: string } = { + center: 'text-center', + left: 'text-left', + right: 'text-right' +}; + +const DEFAULT_SECTIONS_ORDER: PanelSection[] = ['logo', 'name', 'tagline', 'details', 'resources']; +const PREVIEW_FONT_SCALE = 0.5; +const PREVIEW_FONT_MIN_PX = 7; +const PREVIEW_LOGO_SCALE = 0.18; +const PREVIEW_LOGO_MAX_PX = 120; +const DEFAULT_INSTANCE_NAME = 'Open Data Capture'; + +type LoginBrandingPanelProps = { + branding?: BrandingConfig | null; + className?: string; + lang?: 'en' | 'fr'; + preview?: boolean; +}; + +const fontStyle = (px: null | number | undefined, preview: boolean): React.CSSProperties => + px ? { fontSize: `${preview ? Math.max(Math.round(px * PREVIEW_FONT_SCALE), PREVIEW_FONT_MIN_PX) : px}px` } : {}; + +// ── Sub-components ──────────────────────────────────────────────────────────── + +type LogoSectionProps = { + alignment: LogoAlignment; + branding?: BrandingConfig | null; + instanceName: string; + preview: boolean; +}; + +const LogoSection = ({ alignment, branding, instanceName, preview }: LogoSectionProps) => { + const logoSrc = branding?.logoSource === 'url' ? (branding.customLogoUrl ?? null) : (branding?.customLogoSrc ?? null); + const [logoLoadFailed, setLogoLoadFailed] = useState(false); + + useEffect(() => { + setLogoLoadFailed(false); + }, [logoSrc]); + + const logoSize: LogoSize = branding?.logoSize ?? 'small'; + const baseW = branding?.customLogoWidth ?? null; + const baseH = branding?.customLogoHeight ?? null; + const useCustomSize = logoSize === 'custom' && (baseW !== null || baseH !== null); + + const { logoImgClass, logoImgStyle } = useMemo(() => { + if (useCustomSize) { + const w = baseW ?? 0; + const h = baseH ?? 0; + if (preview) { + const maxDim = Math.max(w, h); + const scale = maxDim > 0 ? Math.min(PREVIEW_LOGO_MAX_PX / maxDim, PREVIEW_LOGO_SCALE) : 1; + return { + logoImgClass: '', + logoImgStyle: { + height: h > 0 ? `${Math.round(h * scale)}px` : 'auto', + width: w > 0 ? `${Math.round(w * scale)}px` : 'auto' + } satisfies React.CSSProperties + }; + } + return { + logoImgClass: '', + logoImgStyle: { + height: baseH ? `${baseH}px` : 'auto', + width: baseW ? `${baseW}px` : 'auto' + } satisfies React.CSSProperties + }; + } + const presetKey = logoSize === 'custom' ? 'small' : logoSize; + const heightClass = PRESET_LOGO_HEIGHT_CLASS[presetKey][preview ? 'preview' : 'main']; + return { logoImgClass: cn('w-auto', heightClass), logoImgStyle: {} }; + }, [useCustomSize, baseW, baseH, preview, logoSize]); + + return ( +
+ {logoSrc && !logoLoadFailed ? ( + {instanceName} setLogoLoadFailed(true)} + /> + ) : ( + + )} +
+ ); +}; + +type ResourcesSectionProps = { + boldResourceLinks: boolean; + fontSize: null | number | undefined; + lang: 'en' | 'fr'; + links: ResourceLink[]; + preview: boolean; + tc: (slateClass: string) => null | string; + tl: (obj: { en: string; fr: string }) => string; +}; + +const ResourcesSection = ({ boldResourceLinks, fontSize, lang, links, preview, tc, tl }: ResourcesSectionProps) => ( +
+

+ {tl({ en: 'Resources', fr: 'Ressources' })} +

+
+ {links.map((link, index) => { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const linkLabel = link.label?.[lang]?.trim() || link.label?.en?.trim() || link.label?.fr?.trim() || ''; + if (!linkLabel) return null; + return ( + + + {linkLabel} + + ); + })} +
+
+); + +type PanelFooterProps = { + preview: boolean; + showFooterLinks: boolean; + tc: (slateClass: string) => null | string; + tl: (obj: { en: string; fr: string }) => string; +}; + +const PanelFooter = ({ preview, showFooterLinks, tc, tl }: PanelFooterProps) => ( +
+ {showFooterLinks && ( + + )} +

+ © {CURRENT_YEAR} Douglas Neuroinformatics +

+
+); + +// ── Main component ──────────────────────────────────────────────────────────── + +export const LoginBrandingPanel = ({ + branding, + className, + lang: langOverride, + preview = false +}: LoginBrandingPanelProps) => { + const { resolvedLanguage } = useTranslation(); + const lang = langOverride ?? resolvedLanguage; + + const tl = (obj: { en: string; fr: string }): string => obj[lang]; + + const derived = useMemo(() => { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const instanceName = branding?.instanceName?.[lang]?.trim() || DEFAULT_INSTANCE_NAME; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const instanceTagline = branding?.instanceTagline?.[lang]?.trim() || null; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const instanceDetails = branding?.instanceDetails?.[lang]?.trim() || null; + const panelTextColor = branding?.panelTextColor ?? null; + return { + boldDetails: branding?.boldDetails === true, + boldName: branding?.boldName !== false, + boldResourceLinks: branding?.boldResourceLinks === true, + boldTagline: branding?.boldTagline === true, + instanceDetails, + instanceName, + instanceTagline, + logoAlignment: branding?.logoAlignment ?? 'left', + nameAlignment: branding?.nameAlignment ?? 'left', + panelTextColor, + sectionsOrder: + branding?.sectionsOrder?.length === DEFAULT_SECTIONS_ORDER.length + ? branding.sectionsOrder + : DEFAULT_SECTIONS_ORDER, + showDetails: branding?.showDetails !== false, + showFooterLinks: branding?.showFooterLinks ?? true, + showLogo: branding?.showLogo !== false, + showResourceLinks: (branding?.showResourceLinks ?? false) && (branding?.resourceLinks?.length ?? 0) > 0, + showTagline: branding?.showTagline !== false, + tc: (slateClass: string): null | string => (panelTextColor ? null : slateClass) + }; + }, [branding, lang]); + + const sectionNodes = useMemo(() => { + const { + boldDetails, + boldName, + boldResourceLinks, + boldTagline, + instanceDetails, + instanceName, + instanceTagline, + logoAlignment, + nameAlignment, + showDetails, + showLogo, + showResourceLinks, + showTagline, + tc + } = derived; + + const logoNode: React.ReactNode = showLogo ? ( + + ) : null; + + const nameNode: React.ReactNode = ( +

+ {instanceName} +

+ ); + + const taglineNode: React.ReactNode = + showTagline && instanceTagline ? ( +

+ {instanceTagline} +

+ ) : null; + + const detailsNode: React.ReactNode = + showDetails && instanceDetails ? ( +

+ {instanceDetails} +

+ ) : null; + + const resourcesNode: React.ReactNode = showResourceLinks ? ( + + ) : null; + + return { + details: detailsNode, + logo: logoNode, + name: nameNode, + resources: resourcesNode, + tagline: taglineNode + } satisfies { [K in PanelSection]: React.ReactNode }; + }, [derived, branding, preview, lang]); + + const visibleSections = derived.sectionsOrder.filter((s) => sectionNodes[s] !== null); + + return ( +
+
+ +
+ {visibleSections.map((section) => ( +
{sectionNodes[section]}
+ ))} +
+ + +
+ ); +}; diff --git a/apps/web/src/components/LoginBranding/index.ts b/apps/web/src/components/LoginBranding/index.ts new file mode 100644 index 000000000..f04e7d91f --- /dev/null +++ b/apps/web/src/components/LoginBranding/index.ts @@ -0,0 +1 @@ +export * from './LoginBrandingPanel'; diff --git a/apps/web/src/hooks/useNavItems.ts b/apps/web/src/hooks/useNavItems.ts index 3162dd261..b67e22466 100644 --- a/apps/web/src/hooks/useNavItems.ts +++ b/apps/web/src/hooks/useNavItems.ts @@ -9,6 +9,7 @@ import { DatabaseIcon, EyeIcon, LogsIcon, + PaletteIcon, UploadIcon, UserCogIcon, UsersIcon @@ -90,6 +91,14 @@ export function useNavItems() { }), url: '/admin/settings' }); + adminItems.push({ + icon: PaletteIcon, + label: t({ + en: 'Branding', + fr: 'Image de marque' + }), + url: '/admin/branding' + }); adminItems.push({ icon: LogsIcon, label: t('common.auditLogs'), diff --git a/apps/web/src/hooks/useUpdateSetupStateMutation.ts b/apps/web/src/hooks/useUpdateSetupStateMutation.ts index 88037c704..9d7351740 100644 --- a/apps/web/src/hooks/useUpdateSetupStateMutation.ts +++ b/apps/web/src/hooks/useUpdateSetupStateMutation.ts @@ -5,7 +5,15 @@ import axios from 'axios'; import { SETUP_STATE_QUERY_KEY } from './useSetupStateQuery'; -export function useUpdateSetupStateMutation() { +type UpdateSetupStateMutationOptions = { + /** The notification shown when the update succeeds */ + successNotification?: { + message: string; + title: string; + }; +}; + +export function useUpdateSetupStateMutation({ successNotification }: UpdateSetupStateMutationOptions = {}) { const queryClient = useQueryClient(); const addNotification = useNotificationsStore((store) => store.addNotification); return useMutation({ @@ -13,7 +21,13 @@ export function useUpdateSetupStateMutation() { await axios.patch('/v1/setup', data); }, onSuccess() { - addNotification({ type: 'success' }); + if (successNotification) { + addNotification({ + message: successNotification.message, + title: successNotification.title, + type: 'success' + }); + } void queryClient.invalidateQueries({ queryKey: [SETUP_STATE_QUERY_KEY] }); } }); diff --git a/apps/web/src/route-tree.ts b/apps/web/src/route-tree.ts index feccde578..f9f24be07 100644 --- a/apps/web/src/route-tree.ts +++ b/apps/web/src/route-tree.ts @@ -27,11 +27,13 @@ import { Route as AppAdminSettingsRouteImport } from './routes/_app/admin/settin import { Route as AppDatahubSubjectIdRouteRouteImport } from './routes/_app/datahub/$subjectId/route' import { Route as AppAdminUsersIndexRouteImport } from './routes/_app/admin/users/index' import { Route as AppAdminGroupsIndexRouteImport } from './routes/_app/admin/groups/index' +import { Route as AppAdminBrandingIndexRouteImport } from './routes/_app/admin/branding/index' import { Route as AppInstrumentsRenderIdRouteImport } from './routes/_app/instruments/render/$id' import { Route as AppDatahubSubjectIdGraphRouteImport } from './routes/_app/datahub/$subjectId/graph' import { Route as AppDatahubSubjectIdAssignmentsRouteImport } from './routes/_app/datahub/$subjectId/assignments' import { Route as AppAdminUsersCreateRouteImport } from './routes/_app/admin/users/create' import { Route as AppAdminGroupsCreateRouteImport } from './routes/_app/admin/groups/create' +import { Route as AppAdminBrandingLoginPageRouteImport } from './routes/_app/admin/branding/login-page' import { Route as AppAdminAuditLogsRouteImport } from './routes/_app/admin/audit/logs' import { Route as AppDatahubSubjectIdTableIndexRouteImport } from './routes/_app/datahub/$subjectId/table/index' import { Route as AppDatahubSubjectIdTableRecordIdRouteImport } from './routes/_app/datahub/$subjectId/table/$recordId' @@ -127,6 +129,11 @@ const AppAdminGroupsIndexRoute = AppAdminGroupsIndexRouteImport.update({ path: '/admin/groups/', getParentRoute: () => AppRouteRoute, } as any) +const AppAdminBrandingIndexRoute = AppAdminBrandingIndexRouteImport.update({ + id: '/admin/branding/', + path: '/admin/branding/', + getParentRoute: () => AppRouteRoute, +} as any) const AppInstrumentsRenderIdRoute = AppInstrumentsRenderIdRouteImport.update({ id: '/instruments/render/$id', path: '/instruments/render/$id', @@ -154,6 +161,12 @@ const AppAdminGroupsCreateRoute = AppAdminGroupsCreateRouteImport.update({ path: '/admin/groups/create', getParentRoute: () => AppRouteRoute, } as any) +const AppAdminBrandingLoginPageRoute = + AppAdminBrandingLoginPageRouteImport.update({ + id: '/admin/branding/login-page', + path: '/admin/branding/login-page', + getParentRoute: () => AppRouteRoute, + } as any) const AppAdminAuditLogsRoute = AppAdminAuditLogsRouteImport.update({ id: '/admin/audit/logs', path: '/admin/audit/logs', @@ -189,11 +202,13 @@ export interface FileRoutesByFullPath { '/datahub/': typeof AppDatahubIndexRoute '/upload/': typeof AppUploadIndexRoute '/admin/audit/logs': typeof AppAdminAuditLogsRoute + '/admin/branding/login-page': typeof AppAdminBrandingLoginPageRoute '/admin/groups/create': typeof AppAdminGroupsCreateRoute '/admin/users/create': typeof AppAdminUsersCreateRoute '/datahub/$subjectId/assignments': typeof AppDatahubSubjectIdAssignmentsRoute '/datahub/$subjectId/graph': typeof AppDatahubSubjectIdGraphRoute '/instruments/render/$id': typeof AppInstrumentsRenderIdRoute + '/admin/branding/': typeof AppAdminBrandingIndexRoute '/admin/groups/': typeof AppAdminGroupsIndexRoute '/admin/users/': typeof AppAdminUsersIndexRoute '/datahub/$subjectId/table/$recordId': typeof AppDatahubSubjectIdTableRecordIdRoute @@ -216,11 +231,13 @@ export interface FileRoutesByTo { '/datahub': typeof AppDatahubIndexRoute '/upload': typeof AppUploadIndexRoute '/admin/audit/logs': typeof AppAdminAuditLogsRoute + '/admin/branding/login-page': typeof AppAdminBrandingLoginPageRoute '/admin/groups/create': typeof AppAdminGroupsCreateRoute '/admin/users/create': typeof AppAdminUsersCreateRoute '/datahub/$subjectId/assignments': typeof AppDatahubSubjectIdAssignmentsRoute '/datahub/$subjectId/graph': typeof AppDatahubSubjectIdGraphRoute '/instruments/render/$id': typeof AppInstrumentsRenderIdRoute + '/admin/branding': typeof AppAdminBrandingIndexRoute '/admin/groups': typeof AppAdminGroupsIndexRoute '/admin/users': typeof AppAdminUsersIndexRoute '/datahub/$subjectId/table/$recordId': typeof AppDatahubSubjectIdTableRecordIdRoute @@ -245,11 +262,13 @@ export interface FileRoutesById { '/_app/datahub/': typeof AppDatahubIndexRoute '/_app/upload/': typeof AppUploadIndexRoute '/_app/admin/audit/logs': typeof AppAdminAuditLogsRoute + '/_app/admin/branding/login-page': typeof AppAdminBrandingLoginPageRoute '/_app/admin/groups/create': typeof AppAdminGroupsCreateRoute '/_app/admin/users/create': typeof AppAdminUsersCreateRoute '/_app/datahub/$subjectId/assignments': typeof AppDatahubSubjectIdAssignmentsRoute '/_app/datahub/$subjectId/graph': typeof AppDatahubSubjectIdGraphRoute '/_app/instruments/render/$id': typeof AppInstrumentsRenderIdRoute + '/_app/admin/branding/': typeof AppAdminBrandingIndexRoute '/_app/admin/groups/': typeof AppAdminGroupsIndexRoute '/_app/admin/users/': typeof AppAdminUsersIndexRoute '/_app/datahub/$subjectId/table/$recordId': typeof AppDatahubSubjectIdTableRecordIdRoute @@ -274,11 +293,13 @@ export interface FileRouteTypes { | '/datahub/' | '/upload/' | '/admin/audit/logs' + | '/admin/branding/login-page' | '/admin/groups/create' | '/admin/users/create' | '/datahub/$subjectId/assignments' | '/datahub/$subjectId/graph' | '/instruments/render/$id' + | '/admin/branding/' | '/admin/groups/' | '/admin/users/' | '/datahub/$subjectId/table/$recordId' @@ -301,11 +322,13 @@ export interface FileRouteTypes { | '/datahub' | '/upload' | '/admin/audit/logs' + | '/admin/branding/login-page' | '/admin/groups/create' | '/admin/users/create' | '/datahub/$subjectId/assignments' | '/datahub/$subjectId/graph' | '/instruments/render/$id' + | '/admin/branding' | '/admin/groups' | '/admin/users' | '/datahub/$subjectId/table/$recordId' @@ -329,11 +352,13 @@ export interface FileRouteTypes { | '/_app/datahub/' | '/_app/upload/' | '/_app/admin/audit/logs' + | '/_app/admin/branding/login-page' | '/_app/admin/groups/create' | '/_app/admin/users/create' | '/_app/datahub/$subjectId/assignments' | '/_app/datahub/$subjectId/graph' | '/_app/instruments/render/$id' + | '/_app/admin/branding/' | '/_app/admin/groups/' | '/_app/admin/users/' | '/_app/datahub/$subjectId/table/$recordId' @@ -474,6 +499,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppAdminGroupsIndexRouteImport parentRoute: typeof AppRouteRoute } + '/_app/admin/branding/': { + id: '/_app/admin/branding/' + path: '/admin/branding' + fullPath: '/admin/branding/' + preLoaderRoute: typeof AppAdminBrandingIndexRouteImport + parentRoute: typeof AppRouteRoute + } '/_app/instruments/render/$id': { id: '/_app/instruments/render/$id' path: '/instruments/render/$id' @@ -509,6 +541,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppAdminGroupsCreateRouteImport parentRoute: typeof AppRouteRoute } + '/_app/admin/branding/login-page': { + id: '/_app/admin/branding/login-page' + path: '/admin/branding/login-page' + fullPath: '/admin/branding/login-page' + preLoaderRoute: typeof AppAdminBrandingLoginPageRouteImport + parentRoute: typeof AppRouteRoute + } '/_app/admin/audit/logs': { id: '/_app/admin/audit/logs' path: '/admin/audit/logs' @@ -569,9 +608,11 @@ interface AppRouteRouteChildren { AppDatahubIndexRoute: typeof AppDatahubIndexRoute AppUploadIndexRoute: typeof AppUploadIndexRoute AppAdminAuditLogsRoute: typeof AppAdminAuditLogsRoute + AppAdminBrandingLoginPageRoute: typeof AppAdminBrandingLoginPageRoute AppAdminGroupsCreateRoute: typeof AppAdminGroupsCreateRoute AppAdminUsersCreateRoute: typeof AppAdminUsersCreateRoute AppInstrumentsRenderIdRoute: typeof AppInstrumentsRenderIdRoute + AppAdminBrandingIndexRoute: typeof AppAdminBrandingIndexRoute AppAdminGroupsIndexRoute: typeof AppAdminGroupsIndexRoute AppAdminUsersIndexRoute: typeof AppAdminUsersIndexRoute } @@ -592,9 +633,11 @@ const AppRouteRouteChildren: AppRouteRouteChildren = { AppDatahubIndexRoute: AppDatahubIndexRoute, AppUploadIndexRoute: AppUploadIndexRoute, AppAdminAuditLogsRoute: AppAdminAuditLogsRoute, + AppAdminBrandingLoginPageRoute: AppAdminBrandingLoginPageRoute, AppAdminGroupsCreateRoute: AppAdminGroupsCreateRoute, AppAdminUsersCreateRoute: AppAdminUsersCreateRoute, AppInstrumentsRenderIdRoute: AppInstrumentsRenderIdRoute, + AppAdminBrandingIndexRoute: AppAdminBrandingIndexRoute, AppAdminGroupsIndexRoute: AppAdminGroupsIndexRoute, AppAdminUsersIndexRoute: AppAdminUsersIndexRoute, } diff --git a/apps/web/src/routes/_app/admin/branding/index.tsx b/apps/web/src/routes/_app/admin/branding/index.tsx new file mode 100644 index 000000000..1ace5c946 --- /dev/null +++ b/apps/web/src/routes/_app/admin/branding/index.tsx @@ -0,0 +1,60 @@ +import { Heading } from '@douglasneuroinformatics/libui/components'; +import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { Logo } from '@opendatacapture/react-core'; +import { createFileRoute, Link } from '@tanstack/react-router'; + +import { PageHeader } from '@/components/PageHeader'; + +const LoginPagePreview = () => ( +
+
+ +
+
+
+
+ +
+
+
+
+
+
+); + +const RouteComponent = () => { + const { t } = useTranslation(); + + return ( +
+ + + {t({ en: 'Branding', fr: 'Image de marque' })} + + +
+ +
+ +
+

{t({ en: 'Login Page', fr: 'Page de connexion' })}

+

+ {t({ + en: 'Customize the branding panel, colors, logo, and text shown on the login page.', + fr: 'Personnalisez le panneau de marque, les couleurs, le logo et le texte affichés sur la page de connexion.' + })} +

+
+
+ +
+
+ ); +}; + +export const Route = createFileRoute('/_app/admin/branding/')({ + component: RouteComponent +}); diff --git a/apps/web/src/routes/_app/admin/branding/login-page.tsx b/apps/web/src/routes/_app/admin/branding/login-page.tsx new file mode 100644 index 000000000..ce89b3905 --- /dev/null +++ b/apps/web/src/routes/_app/admin/branding/login-page.tsx @@ -0,0 +1,1489 @@ +/** + * Admin "Customize Login Page" route. + * + * Lets an administrator brand the login page: instance name/tagline/details, + * logo, resource links, panel gradients, per-section font sizes, bold and + * alignment, and the left-panel text color. All edits live in local `form` + * state and are rendered live by (left) plus a mock form + * (right); the same shape is persisted via the setup-state mutation on Save. + * + * A few cross-cutting concerns to keep in mind when editing: + * - `form` is the single source of truth; `previewBranding` and the submit + * payload are both derived from it (keep the two in sync when adding fields). + * - An unsaved-changes guard (useBlocker) compares a JSON snapshot of `form`. + * - Empty strings are normalized to `null` on save so the panel uses defaults. + */ +import React, { useEffect, useMemo, useRef, useState } from 'react'; + +import { + Button, + Card, + Checkbox, + Dialog, + Heading, + Input, + Label, + RadioGroup, + Select, + Tabs, + TextArea +} from '@douglasneuroinformatics/libui/components'; +import { useNotificationsStore, useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { cn } from '@douglasneuroinformatics/libui/utils'; +import { Logo } from '@opendatacapture/react-core'; +import { FONT_SIZES, LOGIN_THEMES, LOGO_ALIGNMENTS, LOGO_SIZES, PANEL_SECTIONS } from '@opendatacapture/schemas/setup'; +import type { + BrandingConfig, + BrandingText, + LoginTheme, + LogoAlignment, + LogoSize, + LogoSource, + PanelSection +} from '@opendatacapture/schemas/setup'; +import { createFileRoute, useBlocker } from '@tanstack/react-router'; +import { ChevronDownIcon, ChevronUpIcon, MaximizeIcon, PlusIcon, TrashIcon, UploadIcon, XIcon } from 'lucide-react'; + +import { LoginBrandingPanel } from '@/components/LoginBranding'; +import { PageHeader } from '@/components/PageHeader'; +import { useSetupStateQuery } from '@/hooks/useSetupStateQuery'; +import { useUpdateSetupStateMutation } from '@/hooks/useUpdateSetupStateMutation'; +import { getLoginGradient, getRightPanelGradient, LOGIN_THEME_COLORS } from '@/utils/branding'; + +/** + * Options for the right-panel theme picker: 'default' (no override) + a curated + * subset of LoginTheme values. Sunset is intentionally omitted to keep the + * swatch grid at 8 cells (2 rows × 4 columns). + */ +const RIGHT_PANEL_OPTIONS = ['none', 'slate', 'ocean', 'forest', 'violet', 'rose', 'midnight', 'custom'] as const; +type RightPanelOption = (typeof RIGHT_PANEL_OPTIONS)[number]; + +const RIGHT_PANEL_LABELS: { [K in RightPanelOption]: { en: string; fr: string } } = { + custom: { en: 'Custom', fr: 'Personnalisé' }, + forest: { en: 'Forest', fr: 'Forêt' }, + midnight: { en: 'Midnight', fr: 'Minuit' }, + none: { en: 'Default', fr: 'Par défaut' }, + ocean: { en: 'Ocean', fr: 'Océan' }, + rose: { en: 'Rose', fr: 'Rose' }, + slate: { en: 'Slate', fr: 'Ardoise' }, + violet: { en: 'Violet', fr: 'Violet' } +}; + +// ── Constants ───────────────────────────────────────────────────────────────── + +const THEME_LABELS: { [K in LoginTheme]: { en: string; fr: string } } = { + custom: { en: 'Custom', fr: 'Personnalisé' }, + forest: { en: 'Forest', fr: 'Forêt' }, + midnight: { en: 'Midnight', fr: 'Minuit' }, + ocean: { en: 'Ocean', fr: 'Océan' }, + rose: { en: 'Rose', fr: 'Rose' }, + slate: { en: 'Slate', fr: 'Ardoise' }, + sunset: { en: 'Sunset', fr: 'Coucher de soleil' }, + violet: { en: 'Violet', fr: 'Violet' } +}; + +const LOGO_SIZE_LABELS: { [K in LogoSize]: { en: string; fr: string } } = { + custom: { en: 'Custom', fr: 'Personnalisé' }, + large: { en: 'Large', fr: 'Grand' }, + medium: { en: 'Medium', fr: 'Moyen' }, + small: { en: 'Small', fr: 'Petit' }, + xlarge: { en: 'Extra Large', fr: 'Très grand' } +}; + +const LOGO_ALIGNMENT_LABELS: { [K in LogoAlignment]: { en: string; fr: string } } = { + center: { en: 'Center', fr: 'Centre' }, + left: { en: 'Left', fr: 'Gauche' }, + right: { en: 'Right', fr: 'Droite' } +}; + +const SECTION_TITLES: { [K in PanelSection]: { en: string; fr: string } } = { + details: { en: 'Details', fr: 'Détails' }, + logo: { en: 'Logo', fr: 'Logo' }, + name: { en: 'Instance Name', fr: "Nom de l'instance" }, + resources: { en: 'Resources', fr: 'Ressources' }, + tagline: { en: 'Main Description', fr: 'Description principale' } +}; + +const DEFAULT_SECTIONS_ORDER: PanelSection[] = ['logo', 'name', 'tagline', 'details', 'resources']; +const HEX_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/; +/** Accept http(s) URLs with a hostname containing at least one dot (e.g. example.com). */ +const URL_PATTERN = /^https?:\/\/[^\s/]+\.[^\s/]+(\/\S*)?$/; +const MAX_LOGO_BYTES = 1024 * 1024; +const FORM_ID = 'branding-form'; +/** Sentinel Select value representing "no override — use the default font size". */ +const FONT_SIZE_DEFAULT = 'default'; +/** Seed color for the left-panel text picker — matches the panel's default `text-slate-100`. */ +const DEFAULT_PANEL_TEXT_COLOR = '#f1f5f9'; + +// ── Types ───────────────────────────────────────────────────────────────────── + +type FormState = { + boldDetails: boolean; + boldName: boolean; + boldResourceLinks: boolean; + boldTagline: boolean; + customLogoHeight: string; + /** Uploaded logo image as a data URI (the 'upload' slot) */ + customLogoSrc: string; + /** External logo image URL (the 'url' slot) */ + customLogoUrl: string; + customLogoWidth: string; + customPrimaryColor: string; + customSecondaryColor: string; + /** Per-section font size (px), or null to use the default size */ + detailsFontSize: null | number; + enableBranding: boolean; + instanceDetails: { en: string; fr: string }; + instanceName: { en: string; fr: string }; + instanceTagline: { en: string; fr: string }; + loginTheme: LoginTheme; + logoAlignment: LogoAlignment; + logoSize: LogoSize; + /** Which logo slot is active: 'upload' or 'url' */ + logoSource: LogoSource; + nameAlignment: LogoAlignment; + nameFontSize: null | number; + /** Hex color applied to all left-panel text (always set; seeded with the default). */ + panelTextColor: string; + resourceLinks: { href: string; label: { en: string; fr: string } }[]; + resourceLinksFontSize: null | number; + /** 'none' means no right-panel override; otherwise one of LoginTheme */ + rightPanelOption: RightPanelOption; + rightPanelPrimaryColor: string; + rightPanelSecondaryColor: string; + sectionsOrder: PanelSection[]; + showDetails: boolean; + showFooterLinks: boolean; + showLogo: boolean; + showResourceLinks: boolean; + showTagline: boolean; + taglineFontSize: null | number; +}; + +const buildFormState = (saved: BrandingConfig | null | undefined): FormState => { + const savedLogoSrc = saved?.customLogoSrc ?? ''; + const savedLogoUrl = saved?.customLogoUrl ?? ''; + const legacyUrlInSrc = !savedLogoUrl && savedLogoSrc !== '' && !savedLogoSrc.startsWith('data:'); + return { + boldDetails: saved?.boldDetails === true, + boldName: saved?.boldName !== false, + boldResourceLinks: saved?.boldResourceLinks === true, + boldTagline: saved?.boldTagline === true, + customLogoHeight: saved?.customLogoHeight ? String(saved.customLogoHeight) : '', + customLogoSrc: legacyUrlInSrc ? '' : savedLogoSrc, + customLogoUrl: legacyUrlInSrc ? savedLogoSrc : savedLogoUrl, + customLogoWidth: saved?.customLogoWidth ? String(saved.customLogoWidth) : '', + customPrimaryColor: saved?.customPrimaryColor ?? LOGIN_THEME_COLORS.ocean.primary, + customSecondaryColor: saved?.customSecondaryColor ?? LOGIN_THEME_COLORS.ocean.secondary, + detailsFontSize: saved?.detailsFontSize ?? null, + enableBranding: saved?.enableBranding === true, + instanceDetails: { en: saved?.instanceDetails?.en ?? '', fr: saved?.instanceDetails?.fr ?? '' }, + instanceName: { en: saved?.instanceName?.en ?? '', fr: saved?.instanceName?.fr ?? '' }, + instanceTagline: { en: saved?.instanceTagline?.en ?? '', fr: saved?.instanceTagline?.fr ?? '' }, + loginTheme: saved?.loginTheme ?? 'slate', + logoAlignment: saved?.logoAlignment ?? 'left', + logoSize: saved?.logoSize ?? 'small', + logoSource: saved?.logoSource ?? (legacyUrlInSrc ? 'url' : 'upload'), + nameAlignment: saved?.nameAlignment ?? 'left', + nameFontSize: saved?.nameFontSize ?? null, + panelTextColor: saved?.panelTextColor ?? DEFAULT_PANEL_TEXT_COLOR, + resourceLinks: saved?.resourceLinks?.length + ? saved.resourceLinks.map((l) => ({ href: l.href, label: { en: l.label?.en ?? '', fr: l.label?.fr ?? '' } })) + : [], + resourceLinksFontSize: saved?.resourceLinksFontSize ?? null, + rightPanelOption: RIGHT_PANEL_OPTIONS.includes((saved?.rightPanelTheme ?? 'none') as RightPanelOption) + ? ((saved?.rightPanelTheme ?? 'none') as RightPanelOption) + : 'none', + rightPanelPrimaryColor: saved?.rightPanelPrimaryColor ?? LOGIN_THEME_COLORS.slate.primary, + rightPanelSecondaryColor: saved?.rightPanelSecondaryColor ?? LOGIN_THEME_COLORS.slate.secondary, + sectionsOrder: + saved?.sectionsOrder?.length === PANEL_SECTIONS.length ? saved.sectionsOrder : DEFAULT_SECTIONS_ORDER, + showDetails: saved?.showDetails !== false, + showFooterLinks: saved?.showFooterLinks ?? true, + showLogo: saved?.showLogo !== false, + showResourceLinks: saved?.showResourceLinks ?? false, + showTagline: saved?.showTagline !== false, + taglineFontSize: saved?.taglineFontSize ?? null + }; +}; + +/** + * Serialize form state for the unsaved-changes check. The uploaded-logo data URI + * (`customLogoSrc`) can be megabytes, so it is collapsed to a short fingerprint + * (length + head + tail) — collision-resistant but cheap on every keystroke. + */ +const snapshotForm = (form: FormState): string => { + const s = form.customLogoSrc; + return JSON.stringify({ ...form, customLogoSrc: `${s.length}:${s.slice(0, 32)}:${s.slice(-32)}` }); +}; + +// ── Component ───────────────────────────────────────────────────────────────── + +const RouteComponent = () => { + const { t } = useTranslation(); + const addNotification = useNotificationsStore((store) => store.addNotification); + const setupStateQuery = useSetupStateQuery(); + const fileInputRef = useRef(null); + const [showFullscreen, setShowFullscreen] = useState(false); + const [previewLang, setPreviewLang] = useState<'en' | 'fr'>('en'); + + const updateSetupStateMutation = useUpdateSetupStateMutation({ + successNotification: { + message: t({ en: 'The login page has been updated.', fr: 'La page de connexion a été mise à jour.' }), + title: t({ en: 'Success', fr: 'Succès' }) + } + }); + + const saved = setupStateQuery.data.branding; + + const [form, setForm] = useState(() => buildFormState(saved)); + + // ── Unsaved-changes guard ─────────────────────────────────────────────── + // Snapshot of the form state as it appeared when last saved (or on mount). + // Used to detect unsaved edits and warn the user before they navigate away. + // Memoized so the (potentially large) form is serialized only when it changes. + const formSnapshot = useMemo(() => snapshotForm(form), [form]); + const savedSnapshotRef = useRef(formSnapshot); + const isDirty = formSnapshot !== savedSnapshotRef.current; + + // Rehydrate the form when the server data changes (e.g. after a save triggers + // a refetch) — but only when there are no unsaved local edits. + useEffect(() => { + if (!isDirty) { + const fresh = buildFormState(saved); + setForm(fresh); + savedSnapshotRef.current = snapshotForm(fresh); + } + }, [saved]); + + // Block in-app navigation (TanStack Router back button, link clicks, etc.) + // and the native `beforeunload` (tab close / refresh / external nav) when dirty. + // `withResolver` gives us proceed/reset controls to drive a custom dialog + // instead of the native confirm() — lets us show Yes/No with No as default. + const blocker = useBlocker({ + enableBeforeUnload: () => isDirty, + shouldBlockFn: () => isDirty, + withResolver: true + }); + + // ── State helpers ──────────────────────────────────────────────────────── + + const update = (key: K, value: FormState[K]) => + setForm((prev) => ({ ...prev, [key]: value })); + + const updateText = ( + field: 'instanceDetails' | 'instanceName' | 'instanceTagline', + lang: 'en' | 'fr', + value: string + ) => setForm((prev) => ({ ...prev, [field]: { ...prev[field], [lang]: value } })); + + const updateResourceLinkHref = (index: number, value: string) => + setForm((prev) => ({ + ...prev, + resourceLinks: prev.resourceLinks.map((l, i) => (i === index ? { ...l, href: value } : l)) + })); + + const updateResourceLinkLabel = (index: number, lang: 'en' | 'fr', value: string) => + setForm((prev) => ({ + ...prev, + resourceLinks: prev.resourceLinks.map((l, i) => + i === index ? { ...l, label: { ...l.label, [lang]: value } } : l + ) + })); + + const addResourceLink = () => + setForm((prev) => ({ + ...prev, + resourceLinks: [...prev.resourceLinks, { href: '', label: { en: '', fr: '' } }] + })); + + const removeResourceLink = (index: number) => + setForm((prev) => ({ ...prev, resourceLinks: prev.resourceLinks.filter((_, i) => i !== index) })); + + const moveSection = (fromIndex: number, toIndex: number) => + setForm((prev) => { + const order = [...prev.sectionsOrder]; + const [item] = order.splice(fromIndex, 1); + order.splice(toIndex, 0, item!); + return { ...prev, sectionsOrder: order }; + }); + + const handleLogoFile = (file: File | undefined) => { + if (!file) return; + if (file.size > MAX_LOGO_BYTES) { + addNotification({ + message: t({ en: 'The selected image is larger than 1 MB.', fr: "L'image sélectionnée dépasse 1 Mo." }), + title: t({ en: 'File too large', fr: 'Fichier trop volumineux' }), + type: 'error' + }); + return; + } + const reader = new FileReader(); + reader.onload = () => update('customLogoSrc', reader.result as string); + reader.readAsDataURL(file); + }; + + const parseDimension = (v: string): null | number => { + const n = Number.parseInt(v.trim(), 10); + return !v.trim() || Number.isNaN(n) || n <= 0 || n > 5000 ? null : n; + }; + + // ── Derived ────────────────────────────────────────────────────────────── + + const customWidth = parseDimension(form.customLogoWidth); + const customHeight = parseDimension(form.customLogoHeight); + + const isCustomSizeInvalid = + form.logoSize === 'custom' && + ((form.customLogoWidth.trim() !== '' && customWidth === null) || + (form.customLogoHeight.trim() !== '' && customHeight === null) || + (customWidth === null && customHeight === null)); + + const isCustomColorInvalid = + form.loginTheme === 'custom' && + (!HEX_PATTERN.test(form.customPrimaryColor) || !HEX_PATTERN.test(form.customSecondaryColor)); + + const isRightPanelCustomColorInvalid = + form.rightPanelOption === 'custom' && + (!HEX_PATTERN.test(form.rightPanelPrimaryColor) || !HEX_PATTERN.test(form.rightPanelSecondaryColor)); + + // The left-panel text color is always shown (no enable toggle), so it just + // needs to be a valid hex; an empty/garbage value disables Save. + const isPanelTextColorInvalid = !HEX_PATTERN.test(form.panelTextColor); + + const isResourceLinkHrefInvalid = (href: string): boolean => { + const trimmed = href.trim(); + return !trimmed || !URL_PATTERN.test(trimmed); + }; + const hasInvalidResourceLinks = + form.showResourceLinks && + form.resourceLinks.some((l) => (!l.label.en.trim() && !l.label.fr.trim()) || isResourceLinkHrefInvalid(l.href)); + + const isSubmitDisabled = + isCustomColorInvalid || + isRightPanelCustomColorInvalid || + isPanelTextColorInvalid || + isCustomSizeInvalid || + hasInvalidResourceLinks || + updateSetupStateMutation.isPending; + + // Live-preview branding object. Mirrors the shape persisted by handleSubmit so + // the on-screen preview reflects exactly what saving would produce. Unlike the + // submit payload, it keeps the raw form values (e.g. an in-progress logo URL) + // so the preview updates as the admin types; the `null` coalescing only guards + // values the panel can't render (empty logo, non-custom theme colors). + const previewBranding: BrandingConfig = { + boldDetails: form.boldDetails, + boldName: form.boldName, + boldResourceLinks: form.boldResourceLinks, + boldTagline: form.boldTagline, + customLogoHeight: customHeight, + customLogoSrc: form.customLogoSrc || null, + customLogoUrl: form.customLogoUrl || null, + customLogoWidth: customWidth, + customPrimaryColor: form.customPrimaryColor, + customSecondaryColor: form.customSecondaryColor, + detailsFontSize: form.detailsFontSize, + enableBranding: form.enableBranding, + instanceDetails: form.instanceDetails, + instanceName: form.instanceName, + instanceTagline: form.instanceTagline, + loginTheme: form.loginTheme, + logoAlignment: form.logoAlignment, + logoSize: form.logoSize, + logoSource: form.logoSource, + nameAlignment: form.nameAlignment, + nameFontSize: form.nameFontSize, + panelTextColor: HEX_PATTERN.test(form.panelTextColor) ? form.panelTextColor : null, + resourceLinks: form.resourceLinks, + resourceLinksFontSize: form.resourceLinksFontSize, + rightPanelPrimaryColor: form.rightPanelOption === 'custom' ? form.rightPanelPrimaryColor : null, + rightPanelSecondaryColor: form.rightPanelOption === 'custom' ? form.rightPanelSecondaryColor : null, + rightPanelTheme: form.rightPanelOption === 'none' ? null : form.rightPanelOption, + sectionsOrder: form.sectionsOrder, + showDetails: form.showDetails, + showFooterLinks: form.showFooterLinks, + showLogo: form.showLogo, + showResourceLinks: form.showResourceLinks, + showTagline: form.showTagline, + taglineFontSize: form.taglineFontSize + }; + + // ── Submit ─────────────────────────────────────────────────────────────── + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + // Guard again on submit in case Enter bypassed the disabled Save button. + if ( + isCustomColorInvalid || + isRightPanelCustomColorInvalid || + isPanelTextColorInvalid || + isCustomSizeInvalid || + hasInvalidResourceLinks + ) + return; + // Empty text fields persist as `null` rather than '' so the panel falls back + // to its defaults; a BrandingText is omitted entirely when both langs are blank. + const trim = (v: string) => (v.trim() ? v.trim() : null); + const toText = (text: { en: string; fr: string }): BrandingText | null => { + const en = trim(text.en); + const fr = trim(text.fr); + return (en ?? fr) ? { en, fr } : null; + }; + // Drop blank/partial resource links so we never persist half-filled rows. + const cleanedLinks = form.resourceLinks + .map((l) => ({ + href: l.href.trim(), + label: { en: trim(l.label.en), fr: trim(l.label.fr) } + })) + .filter((l) => l.href && (l.label.en ?? l.label.fr)); + // Capture the snapshot at submit time so a concurrent edit during the + // request doesn't get marked clean if the user typed while saving. + const submittedSnapshot = snapshotForm(form); + updateSetupStateMutation.mutate( + { + branding: { + boldDetails: form.boldDetails, + boldName: form.boldName, + boldResourceLinks: form.boldResourceLinks, + boldTagline: form.boldTagline, + customLogoHeight: form.logoSize === 'custom' ? customHeight : null, + customLogoSrc: form.customLogoSrc.trim() || null, + customLogoUrl: form.customLogoUrl.trim() || null, + customLogoWidth: form.logoSize === 'custom' ? customWidth : null, + customPrimaryColor: form.loginTheme === 'custom' ? form.customPrimaryColor : null, + customSecondaryColor: form.loginTheme === 'custom' ? form.customSecondaryColor : null, + detailsFontSize: form.detailsFontSize, + enableBranding: form.enableBranding, + instanceDetails: toText(form.instanceDetails), + instanceName: toText(form.instanceName), + instanceTagline: toText(form.instanceTagline), + loginTheme: form.loginTheme, + logoAlignment: form.logoAlignment, + logoSize: form.logoSize, + logoSource: form.logoSource, + nameAlignment: form.nameAlignment, + nameFontSize: form.nameFontSize, + panelTextColor: HEX_PATTERN.test(form.panelTextColor) ? form.panelTextColor : null, + resourceLinks: form.showResourceLinks ? cleanedLinks : [], + resourceLinksFontSize: form.resourceLinksFontSize, + rightPanelPrimaryColor: form.rightPanelOption === 'custom' ? form.rightPanelPrimaryColor : null, + rightPanelSecondaryColor: form.rightPanelOption === 'custom' ? form.rightPanelSecondaryColor : null, + rightPanelTheme: form.rightPanelOption === 'none' ? null : form.rightPanelOption, + sectionsOrder: form.sectionsOrder, + showDetails: form.showDetails, + showFooterLinks: form.showFooterLinks, + showLogo: form.showLogo, + showResourceLinks: form.showResourceLinks && cleanedLinks.length > 0, + showTagline: form.showTagline, + taglineFontSize: form.taglineFontSize + } + }, + { + onSuccess: () => { + savedSnapshotRef.current = submittedSnapshot; + } + } + ); + }; + + // ── Section card renderer ──────────────────────────────────────────────── + + const orderButtons = (section: PanelSection) => { + const idx = form.sectionsOrder.indexOf(section); + return ( +
+ + +
+ ); + }; + + // Font-size dropdown reused by every text section. `null` = use default size. + const fontSizeField = (id: string, value: null | number, onChange: (v: null | number) => void) => ( +
+ + +
+ ); + + // Bold on/off toggle shown next to a section's "Show" checkbox. + const boldToggle = (id: string, checked: boolean, onChange: (b: boolean) => void) => ( +
+ onChange(c === true)} /> + +
+ ); + + const renderSectionCard = (section: PanelSection): React.ReactNode => { + switch (section) { + case 'details': + return ( + + +
+
+
+ {t(SECTION_TITLES.details)} +
+ update('showDetails', checked === true)} + /> + +
+ {boldToggle('boldDetails', form.boldDetails, (b) => update('boldDetails', b))} +
+ + {t({ + en: 'Additional notes shown on the branding panel', + fr: 'Remarques supplémentaires figurant sur le panneau de marque.' + })} + +
+ {orderButtons('details')} +
+
+ {form.showDetails && ( + +
+
+ +