feat(theme): per-theme brand logos (VNClagoon wordmark ↔ SRC mark)
Add optional logoLightUrl/logoDarkUrl to InstalledTheme + resolveThemeLogo() helper; set logos on vnclagoon + src themes; login page and nav-rail prefer the active theme's logo, falling back to the global config logo. So switching theme switches the whole brand. 0 type errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
1bc5a6a0ce
commit
88670d4bcf
@@ -9,6 +9,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useAccountStore } from "@/stores/account-store";
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
import { useThemeStore } from "@/stores/theme-store";
|
import { useThemeStore } from "@/stores/theme-store";
|
||||||
|
import { resolveThemeLogo } from "@/lib/theme-logo";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useConfig } from "@/hooks/use-config";
|
import { useConfig } from "@/hooks/use-config";
|
||||||
import { apiFetch, getPathPrefix, toRouterPath, withBasePath } from "@/lib/browser-navigation";
|
import { apiFetch, getPathPrefix, toRouterPath, withBasePath } from "@/lib/browser-navigation";
|
||||||
@@ -135,6 +136,10 @@ export default function LoginPage() {
|
|||||||
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
|
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
|
||||||
const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, loginLogoMaxHeight, loginLogoMaxWidth, loginShowHeading, loginShowSubtitle, loginShowTotp, loginShowVersion, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig();
|
const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, loginLogoMaxHeight, loginLogoMaxWidth, loginShowHeading, loginShowSubtitle, loginShowTotp, loginShowVersion, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig();
|
||||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||||
|
const { activeThemeId, installedThemes } = useThemeStore(useShallow((s) => ({ activeThemeId: s.activeThemeId, installedThemes: s.installedThemes })));
|
||||||
|
// Active theme may carry its own brand logo (VNClagoon wordmark, SRC mark);
|
||||||
|
// fall back to the globally configured login logo.
|
||||||
|
const effLoginLogo = resolveThemeLogo(installedThemes, activeThemeId, resolvedTheme === 'dark', loginLogoLightUrl, loginLogoDarkUrl);
|
||||||
|
|
||||||
// Login logo sizing: when a max height/width is configured, drop the fixed
|
// Login logo sizing: when a max height/width is configured, drop the fixed
|
||||||
// 64×64 box so the logo (e.g. a wide wordmark) can render at its true size.
|
// 64×64 box so the logo (e.g. a wide wordmark) can render at its true size.
|
||||||
@@ -740,7 +745,7 @@ export default function LoginPage() {
|
|||||||
<div className="px-8 pt-12 pb-4 text-center">
|
<div className="px-8 pt-12 pb-4 text-center">
|
||||||
<div className="inline-flex items-center justify-center w-20 h-20 mb-6">
|
<div className="inline-flex items-center justify-center w-20 h-20 mb-6">
|
||||||
<img
|
<img
|
||||||
src={withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl)}
|
src={withBasePath(effLoginLogo)}
|
||||||
alt={appName}
|
alt={appName}
|
||||||
className="max-w-20 max-h-20 object-contain"
|
className="max-w-20 max-h-20 object-contain"
|
||||||
/>
|
/>
|
||||||
@@ -890,7 +895,7 @@ export default function LoginPage() {
|
|||||||
<div className="px-8 pt-10 pb-6 text-center">
|
<div className="px-8 pt-10 pb-6 text-center">
|
||||||
<div className={cn("inline-flex items-center justify-center mb-5", !hasLogoSize && "w-16 h-16")}>
|
<div className={cn("inline-flex items-center justify-center mb-5", !hasLogoSize && "w-16 h-16")}>
|
||||||
<img
|
<img
|
||||||
src={withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl)}
|
src={withBasePath(effLoginLogo)}
|
||||||
alt={appName}
|
alt={appName}
|
||||||
className={cn("object-contain", !hasLogoSize && "max-w-16 max-h-16")}
|
className={cn("object-contain", !hasLogoSize && "max-w-16 max-h-16")}
|
||||||
style={loginLogoStyle}
|
style={loginLogoStyle}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { AccountSwitcher } from "./account-switcher";
|
|||||||
import { icons as lucideIcons, type LucideIcon } from "lucide-react";
|
import { icons as lucideIcons, type LucideIcon } from "lucide-react";
|
||||||
import { useConfig } from "@/hooks/use-config";
|
import { useConfig } from "@/hooks/use-config";
|
||||||
import { useThemeStore } from "@/stores/theme-store";
|
import { useThemeStore } from "@/stores/theme-store";
|
||||||
|
import { resolveThemeLogo } from "@/lib/theme-logo";
|
||||||
import { usePathname, Link, useRouter } from "@/i18n/navigation";
|
import { usePathname, Link, useRouter } from "@/i18n/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
@@ -190,6 +191,8 @@ export function NavigationRail({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { appLogoLightUrl, appLogoDarkUrl } = useConfig();
|
const { appLogoLightUrl, appLogoDarkUrl } = useConfig();
|
||||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||||
|
const activeThemeId = useThemeStore((s) => s.activeThemeId);
|
||||||
|
const installedThemes = useThemeStore((s) => s.installedThemes);
|
||||||
const { supportsCalendar } = useCalendarStore();
|
const { supportsCalendar } = useCalendarStore();
|
||||||
const { mailboxes } = useEmailStore();
|
const { mailboxes } = useEmailStore();
|
||||||
const client = useAuthStore((s) => s.client);
|
const client = useAuthStore((s) => s.client);
|
||||||
@@ -462,7 +465,7 @@ export function NavigationRail({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{(() => {
|
{(() => {
|
||||||
const logoUrl = withBasePath(resolvedTheme === 'dark' ? (appLogoDarkUrl || appLogoLightUrl) : (appLogoLightUrl || appLogoDarkUrl));
|
const logoUrl = withBasePath(resolveThemeLogo(installedThemes, activeThemeId, resolvedTheme === 'dark', appLogoLightUrl, appLogoDarkUrl));
|
||||||
return logoUrl ? (
|
return logoUrl ? (
|
||||||
<div className="flex items-center justify-center py-3 px-1">
|
<div className="flex items-center justify-center py-3 px-1">
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -1104,6 +1104,8 @@ export const BUILTIN_THEMES: InstalledTheme[] = [
|
|||||||
description: 'VNClagoon brand theme — deep navy with cyan accent, DM Sans + Syne',
|
description: 'VNClagoon brand theme — deep navy with cyan accent, DM Sans + Syne',
|
||||||
css: vnclagoonCSS,
|
css: vnclagoonCSS,
|
||||||
skin: vnclagoonSkin,
|
skin: vnclagoonSkin,
|
||||||
|
logoLightUrl: '/branding/vncmail-wordmark-on-light.svg',
|
||||||
|
logoDarkUrl: '/branding/vncmail-wordmark-on-dark.svg',
|
||||||
variants: ['light', 'dark'],
|
variants: ['light', 'dark'],
|
||||||
typography: { fontSans: '"DM Sans", system-ui, -apple-system, "Segoe UI", sans-serif' },
|
typography: { fontSans: '"DM Sans", system-ui, -apple-system, "Segoe UI", sans-serif' },
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -1116,6 +1118,8 @@ export const BUILTIN_THEMES: InstalledTheme[] = [
|
|||||||
author: 'VNC',
|
author: 'VNC',
|
||||||
description: 'SRC Advisory brand theme — Swiss red on white, light-first',
|
description: 'SRC Advisory brand theme — Swiss red on white, light-first',
|
||||||
css: srcCSS,
|
css: srcCSS,
|
||||||
|
logoLightUrl: '/branding/src-logo.svg',
|
||||||
|
logoDarkUrl: '/branding/src-logo.svg',
|
||||||
variants: ['light', 'dark'],
|
variants: ['light', 'dark'],
|
||||||
enabled: true,
|
enabled: true,
|
||||||
builtIn: true,
|
builtIn: true,
|
||||||
|
|||||||
@@ -204,6 +204,13 @@ export interface InstalledTheme {
|
|||||||
* lifecycle as `css` to keep localStorage small.
|
* lifecycle as `css` to keep localStorage small.
|
||||||
*/
|
*/
|
||||||
skin?: string;
|
skin?: string;
|
||||||
|
/**
|
||||||
|
* Optional brand logos. When this theme is the active theme, these override
|
||||||
|
* the global login/app logo (a full brand skin, not just colours). Falls back
|
||||||
|
* to the configured global logo when unset.
|
||||||
|
*/
|
||||||
|
logoLightUrl?: string;
|
||||||
|
logoDarkUrl?: string;
|
||||||
variants: ThemeVariant[];
|
variants: ThemeVariant[];
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
builtIn: boolean;
|
builtIn: boolean;
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { InstalledTheme } from './plugin-types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve which logo to show. When the active theme carries brand logos
|
||||||
|
* (logoLightUrl/logoDarkUrl), those win — so switching theme switches the whole
|
||||||
|
* brand (VNClagoon wordmark ↔ SRC mark). Otherwise falls back to the globally
|
||||||
|
* configured logo. Returns a raw path; the caller applies withBasePath().
|
||||||
|
*/
|
||||||
|
export function resolveThemeLogo(
|
||||||
|
themes: InstalledTheme[],
|
||||||
|
activeThemeId: string | null,
|
||||||
|
isDark: boolean,
|
||||||
|
fallbackLight: string,
|
||||||
|
fallbackDark: string,
|
||||||
|
): string {
|
||||||
|
const theme = activeThemeId ? themes.find((t) => t.id === activeThemeId) : undefined;
|
||||||
|
if (theme) {
|
||||||
|
const themed = isDark
|
||||||
|
? (theme.logoDarkUrl ?? theme.logoLightUrl)
|
||||||
|
: (theme.logoLightUrl ?? theme.logoDarkUrl);
|
||||||
|
if (themed) return themed;
|
||||||
|
}
|
||||||
|
return isDark ? (fallbackDark || fallbackLight) : (fallbackLight || fallbackDark);
|
||||||
|
}
|
||||||
@@ -32,6 +32,11 @@ that merging new upstream releases stays a triage exercise, not an archaeology d
|
|||||||
| 2026-08-03 | `stores/theme-store.ts` | default `theme`/`resolvedTheme` → `dark` | dark-first per VNClagoon styleguide |
|
| 2026-08-03 | `stores/theme-store.ts` | default `theme`/`resolvedTheme` → `dark` | dark-first per VNClagoon styleguide |
|
||||||
| 2026-08-03 | `lib/builtin-themes.ts` | add `builtin-src` theme (Swiss red on white, light-first) | 2nd brand theme (SRC Advisory); VNClagoon stays default |
|
| 2026-08-03 | `lib/builtin-themes.ts` | add `builtin-src` theme (Swiss red on white, light-first) | 2nd brand theme (SRC Advisory); VNClagoon stays default |
|
||||||
| 2026-08-03 | `public/branding/src-logo.svg` (new) | SRC mountain mark | SRC brand (placeholder — swap official) |
|
| 2026-08-03 | `public/branding/src-logo.svg` (new) | SRC mountain mark | SRC brand (placeholder — swap official) |
|
||||||
|
| 2026-08-03 | `lib/plugin-types.ts` | add optional `logoLightUrl`/`logoDarkUrl` to InstalledTheme | per-theme brand logos |
|
||||||
|
| 2026-08-03 | `lib/theme-logo.ts` (new) | `resolveThemeLogo()` helper | pick active theme's logo, fall back to global |
|
||||||
|
| 2026-08-03 | `lib/builtin-themes.ts` | set logos on vnclagoon (wordmark) + src (mark) | logo switches with the brand theme |
|
||||||
|
| 2026-08-03 | `app/(main)/[locale]/login/page.tsx` | login logo uses active theme's logo | brand-switch on login |
|
||||||
|
| 2026-08-03 | `components/layout/navigation-rail.tsx` | nav-rail logo uses active theme's logo | brand-switch in app |
|
||||||
|
|
||||||
_Note: Vercel was tried and **abandoned** on 2026-08-03. Bulwark writes to a local
|
_Note: Vercel was tried and **abandoned** on 2026-08-03. Bulwark writes to a local
|
||||||
data dir (`/app/data/*`); Vercel serverless has a read-only filesystem → crash
|
data dir (`/app/data/*`); Vercel serverless has a read-only filesystem → crash
|
||||||
|
|||||||
Reference in New Issue
Block a user