UI/UX + AI chat widget + i18n

This commit is contained in:
HugeFrog24
2026-02-14 12:23:26 +01:00
parent 39b463d1a1
commit 0b4f39edcb
15 changed files with 272 additions and 326 deletions

View File

@@ -86,6 +86,11 @@
"dictationServer": "Server (KI)", "dictationServer": "Server (KI)",
"dictationBrowserUnavailable": "In diesem Browser nicht unterstützt" "dictationBrowserUnavailable": "In diesem Browser nicht unterstützt"
}, },
"Preferences": {
"title": "Einstellungen",
"language": "Sprache",
"theme": "Design"
},
"Common": { "Common": {
"loading": "Wird geladen…", "loading": "Wird geladen…",
"error": "Ein Fehler ist aufgetreten", "error": "Ein Fehler ist aufgetreten",

View File

@@ -86,6 +86,11 @@
"dictationServer": "Server (AI)", "dictationServer": "Server (AI)",
"dictationBrowserUnavailable": "Not supported in this browser" "dictationBrowserUnavailable": "Not supported in this browser"
}, },
"Preferences": {
"title": "Preferences",
"language": "Language",
"theme": "Theme"
},
"Common": { "Common": {
"loading": "Loading…", "loading": "Loading…",
"error": "An error occurred", "error": "An error occurred",

View File

@@ -86,6 +86,11 @@
"dictationServer": "Servidor (IA)", "dictationServer": "Servidor (IA)",
"dictationBrowserUnavailable": "No compatible con este navegador" "dictationBrowserUnavailable": "No compatible con este navegador"
}, },
"Preferences": {
"title": "Preferencias",
"language": "Idioma",
"theme": "Tema"
},
"Common": { "Common": {
"loading": "Cargando…", "loading": "Cargando…",
"error": "Ocurrió un error", "error": "Ocurrió un error",

View File

@@ -86,6 +86,11 @@
"dictationServer": "სერვერი (AI)", "dictationServer": "სერვერი (AI)",
"dictationBrowserUnavailable": "ამ ბრაუზერში არ არის მხარდაჭერილი" "dictationBrowserUnavailable": "ამ ბრაუზერში არ არის მხარდაჭერილი"
}, },
"Preferences": {
"title": "პარამეტრები",
"language": "ენა",
"theme": "თემა"
},
"Common": { "Common": {
"loading": "იტვირთება…", "loading": "იტვირთება…",
"error": "მოხდა შეცდომა", "error": "მოხდა შეცდომა",

View File

@@ -86,6 +86,11 @@
"dictationServer": "Сервер (ИИ)", "dictationServer": "Сервер (ИИ)",
"dictationBrowserUnavailable": "Не поддерживается в этом браузере" "dictationBrowserUnavailable": "Не поддерживается в этом браузере"
}, },
"Preferences": {
"title": "Настройки",
"language": "Язык",
"theme": "Тема"
},
"Common": { "Common": {
"loading": "Загрузка…", "loading": "Загрузка…",
"error": "Произошла ошибка", "error": "Произошла ошибка",

View File

@@ -86,6 +86,11 @@
"dictationServer": "Sunucu (YZ)", "dictationServer": "Sunucu (YZ)",
"dictationBrowserUnavailable": "Bu tarayıcıda desteklenmiyor" "dictationBrowserUnavailable": "Bu tarayıcıda desteklenmiyor"
}, },
"Preferences": {
"title": "Tercihler",
"language": "Dil",
"theme": "Tema"
},
"Common": { "Common": {
"loading": "Yükleniyor…", "loading": "Yükleniyor…",
"error": "Bir hata oluştu", "error": "Bir hata oluştu",

View File

@@ -156,7 +156,7 @@ export default function AdminLogin() {
<div className="min-h-[calc(100vh-var(--header-height,160px))] flex items-center justify-center px-4 sm:px-6 lg:px-8"> <div className="min-h-[calc(100vh-var(--header-height,160px))] flex items-center justify-center px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8"> <div className="max-w-md w-full space-y-8">
<div> <div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white"> <h2 className="text-center text-3xl font-extrabold text-gray-900 dark:text-white">
{t("Login.title")} {t("Login.title")}
</h2> </h2>
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400"> <p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">

View File

@@ -4,7 +4,6 @@ import { NextIntlClientProvider } from "next-intl";
import { getMessages, getTranslations } from "next-intl/server"; import { getMessages, getTranslations } from "next-intl/server";
import { SUPPORTED_LOCALES } from "@/lib/locales"; import { SUPPORTED_LOCALES } from "@/lib/locales";
import { ThemeProvider } from "@/components/theme/ThemeProvider"; import { ThemeProvider } from "@/components/theme/ThemeProvider";
import ThemeSelector from "@/components/theme/ThemeSelector";
import ChatToggle from "@/components/ChatToggle"; import ChatToggle from "@/components/ChatToggle";
import Header from "@/components/Header"; import Header from "@/components/Header";
import LocaleHtmlLang from "@/components/LocaleHtmlLang"; import LocaleHtmlLang from "@/components/LocaleHtmlLang";
@@ -96,7 +95,6 @@ export default async function LocaleLayout({
> >
{children} {children}
<ChatToggle /> <ChatToggle />
<ThemeSelector />
</div> </div>
</ThemeProvider> </ThemeProvider>
</NextIntlClientProvider> </NextIntlClientProvider>

View File

@@ -12,7 +12,7 @@ import { supportedLocaleCodes, getLocaleConfig } from "@/lib/locales";
/** /**
* Layout-level chat bubble button + panel. * Layout-level chat bubble button + panel.
* Sits next to the ThemeSelector in the fixed bottom-right corner. * Sits in the fixed bottom-right corner as the sole floating action button.
* Owns the open/close state; delegates everything else to ChatWidget. * Owns the open/close state; delegates everything else to ChatWidget.
* *
* Also bridges client-side chat tools (e.g. `setTheme`) to app state * Also bridges client-side chat tools (e.g. `setTheme`) to app state
@@ -122,7 +122,7 @@ export default function ChatToggle() {
); );
return ( return (
<div className="fixed bottom-6 right-20 z-50"> <div className="fixed bottom-6 right-6 z-50">
{/* Bubble button — visible when panel is closed */} {/* Bubble button — visible when panel is closed */}
{!isOpen && ( {!isOpen && (
<button <button

View File

@@ -10,7 +10,7 @@ import {
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { Link, useRouter, usePathname } from "@/i18n/navigation"; import { Link, useRouter, usePathname } from "@/i18n/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import LanguageSwitcher from "./LanguageSwitcher"; import PreferencesMenu from "./PreferencesMenu";
import { useHeaderHeightCSS } from "@/hooks/useHeaderHeight"; import { useHeaderHeightCSS } from "@/hooks/useHeaderHeight";
interface HeaderProps { interface HeaderProps {
@@ -219,7 +219,7 @@ export default function Header({
{t("Common.login")} {t("Common.login")}
</Link> </Link>
)} )}
<LanguageSwitcher /> <PreferencesMenu />
</div> </div>
</div> </div>

View File

@@ -1,64 +0,0 @@
"use client";
import { useLocale } from "next-intl";
import { useState } from "react";
import { useRouter, usePathname } from "@/i18n/navigation";
import { GlobeAltIcon } from "@heroicons/react/24/outline";
import { SUPPORTED_LOCALES, getLocaleConfig } from "@/lib/locales";
export default function LanguageSwitcher() {
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
const [isOpen, setIsOpen] = useState(false);
const handleLanguageChange = (newLocale: string) => {
// Navigate to the same path under the new locale.
// The next-intl middleware handles cookie management automatically.
router.replace(pathname, { locale: newLocale });
};
const currentLanguage = getLocaleConfig(locale) || SUPPORTED_LOCALES[0];
return (
<div className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center space-x-2 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"
aria-label="Change language"
>
<GlobeAltIcon className="h-5 w-5" />
<span>{currentLanguage.flag}</span>
<span className="hidden sm:inline">{currentLanguage.name}</span>
</button>
{isOpen && (
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-md shadow-lg ring-1 ring-black ring-opacity-5 z-50">
<div className="py-1">
{SUPPORTED_LOCALES.map((language) => (
<button
key={language.code}
onClick={() => {
handleLanguageChange(language.code);
setIsOpen(false);
}}
className={`
w-full text-left px-4 py-2 text-sm flex items-center space-x-3
${
language.code === locale
? "bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white"
: "text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
}
transition-colors
`}
>
<span>{language.flag}</span>
<span>{language.name}</span>
</button>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,235 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { useLocale, useTranslations } from "next-intl";
import { useRouter, usePathname } from "@/i18n/navigation";
import { Cog6ToothIcon, ChevronDownIcon } from "@heroicons/react/24/outline";
import { useTheme } from "./theme/ThemeProvider";
import { SUPPORTED_LOCALES, getLocaleConfig } from "@/lib/locales";
import {
ACCENT_COLORS,
COLOR_SCHEMES,
type AccentColor,
type ColorScheme,
} from "@/types/theme";
/**
* Unified preferences dropdown with accordion sections.
*
* Replaces the standalone LanguageSwitcher and floating ThemeSelector
* with a single "Start Menu"-style panel in the header.
*
* Sections:
* - Language (locale picker)
* - Theme (color scheme + accent color)
*/
export default function PreferencesMenu() {
const t = useTranslations();
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
const { theme, setAccentColor, setColorScheme, systemPrefersDark } =
useTheme();
const [isOpen, setIsOpen] = useState(false);
const [openSection, setOpenSection] = useState<string | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
// Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
// Close on Escape
useEffect(() => {
if (!isOpen) return;
function handleEscape(event: KeyboardEvent) {
if (event.key === "Escape") setIsOpen(false);
}
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [isOpen]);
const toggleSection = (section: string) => {
setOpenSection((prev) => (prev === section ? null : section));
};
const handleLanguageChange = (newLocale: string) => {
router.replace(pathname, { locale: newLocale });
setIsOpen(false);
};
// Find the current locale config for the trigger badge
const currentLocale = getLocaleConfig(locale);
return (
<div className="relative" ref={menuRef}>
{/* Trigger button */}
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-accent-600 dark:hover:text-accent-400 transition-colors"
aria-label={t("Preferences.title")}
aria-expanded={isOpen}
aria-haspopup="true"
>
<Cog6ToothIcon className="h-5 w-5" />
{currentLocale && <span>{currentLocale.flag}</span>}
<ChevronDownIcon
className={`h-3.5 w-3.5 transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`}
/>
</button>
{/* Dropdown panel */}
{isOpen && (
<div
className="absolute right-0 mt-2 w-64 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-xl z-50 overflow-hidden animate-in slide-in-from-top-2 duration-150"
role="menu"
aria-label={t("Preferences.title")}
>
{/* ── Language section ─────────────────────────────────── */}
<div>
<button
onClick={() => toggleSection("language")}
className="w-full flex items-center justify-between px-4 py-3 text-sm font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
aria-expanded={openSection === "language"}
>
<span>{t("Preferences.language")}</span>
<ChevronDownIcon
className={`h-4 w-4 text-gray-400 transition-transform duration-200 ${openSection === "language" ? "rotate-180" : ""}`}
/>
</button>
{openSection === "language" && (
<div className="px-2 pb-2">
{SUPPORTED_LOCALES.map((lang) => {
const isSelected = lang.code === locale;
return (
<button
key={lang.code}
onClick={() => handleLanguageChange(lang.code)}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors ${
isSelected
? "bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white"
: "text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50"
}`}
role="menuitem"
>
<span>{lang.flag}</span>
<span className="flex-1 text-left">{lang.name}</span>
{isSelected && (
<div className="w-2 h-2 bg-accent-500 rounded-full flex-shrink-0" />
)}
</button>
);
})}
</div>
)}
</div>
<div className="border-t border-gray-200 dark:border-gray-700" />
{/* ── Theme section ────────────────────────────────────── */}
<div>
<button
onClick={() => toggleSection("theme")}
className="w-full flex items-center justify-between px-4 py-3 text-sm font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
aria-expanded={openSection === "theme"}
>
<span>{t("Preferences.theme")}</span>
<ChevronDownIcon
className={`h-4 w-4 text-gray-400 transition-transform duration-200 ${openSection === "theme" ? "rotate-180" : ""}`}
/>
</button>
{openSection === "theme" && (
<div className="px-2 pb-2 space-y-2">
{/* Appearance (color scheme) */}
<div>
<div className="px-2 pb-1 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t("Theme.appearance")}
</div>
{Object.entries(COLOR_SCHEMES).map(([key, info]) => {
const scheme = key as ColorScheme;
const isSelected = theme.colorScheme === scheme;
return (
<button
key={scheme}
onClick={() => { setColorScheme(scheme); setIsOpen(false); }}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors ${
isSelected
? "bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white"
: "text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50"
}`}
role="menuitem"
>
<span className="text-base flex-shrink-0">
{info.icon}
</span>
<span className="flex-1 text-left">
{t(`Theme.schemes.${scheme}`)}
</span>
{scheme === "system" && (
<span className="text-xs text-gray-500 dark:text-gray-400">
(
{systemPrefersDark
? t("Theme.dark")
: t("Theme.light")}
)
</span>
)}
{isSelected && (
<div className="w-2 h-2 bg-accent-500 rounded-full flex-shrink-0" />
)}
</button>
);
})}
</div>
{/* Accent color */}
<div>
<div className="px-2 pb-1 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t("Theme.accentColor")}
</div>
{Object.entries(ACCENT_COLORS).map(([key, info]) => {
const color = key as AccentColor;
const isSelected = theme.accent === color;
return (
<button
key={color}
onClick={() => { setAccentColor(color); setIsOpen(false); }}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors ${
isSelected
? "bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white"
: "text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50"
}`}
role="menuitem"
>
<div
className="w-4 h-4 rounded-full border border-gray-300 dark:border-gray-600 flex-shrink-0"
style={{ backgroundColor: info.preview }}
aria-hidden="true"
/>
<span className="flex-1 text-left">
{t(`Theme.colors.${color}`)}
</span>
{isSelected && (
<div className="w-2 h-2 bg-accent-500 rounded-full flex-shrink-0" />
)}
</button>
);
})}
</div>
</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -1,253 +0,0 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { useTheme } from "./ThemeProvider";
import {
ACCENT_COLORS,
COLOR_SCHEMES,
AccentColor,
ColorScheme,
} from "@/types/theme";
import { SwatchIcon } from "@heroicons/react/24/outline";
import { useTranslations } from "next-intl";
export default function ThemeSelector() {
const { theme, setAccentColor, setColorScheme, systemPrefersDark } =
useTheme();
const [isOpen, setIsOpen] = useState(false);
const [isAnimating, setIsAnimating] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const t = useTranslations();
// Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
dropdownRef.current &&
buttonRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
!buttonRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
// Close dropdown on escape key
useEffect(() => {
function handleEscape(event: KeyboardEvent) {
if (event.key === "Escape") {
setIsOpen(false);
}
}
if (isOpen) {
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("keydown", handleEscape);
};
}
}, [isOpen]);
// Radial animation effect every 15 seconds when closed
useEffect(() => {
const interval = setInterval(() => {
if (!isOpen) {
// Only animate when dropdown is closed
setIsAnimating(true);
setTimeout(() => setIsAnimating(false), 2000); // Animation duration
}
}, 15000); // Every 15 seconds
return () => clearInterval(interval);
}, [isOpen]); // Include isOpen dependency
const handleColorSelect = (color: AccentColor) => {
setAccentColor(color);
setIsOpen(false);
};
const handleColorSchemeSelect = (scheme: ColorScheme) => {
setColorScheme(scheme);
setIsOpen(false);
};
// Unified glow ring configuration
const glowRings = [
{ opacity: "/50", blur: "", scale: isOpen ? "scale-200" : "", delay: "0s" },
{
opacity: "/35",
blur: "blur-[1px]",
scale: isOpen ? "scale-175" : "",
delay: "0.3s",
},
{
opacity: "/20",
blur: "blur-[2px]",
scale: isOpen ? "scale-150" : "",
delay: "0.6s",
},
];
const shouldShowGlow = isOpen || isAnimating;
return (
<div className="fixed bottom-6 right-6 z-50">
{/* Theme selector button with unified radial glow system */}
<div className="relative">
{/* Unified glow rings - permanent when open, animated when pulsing */}
{shouldShowGlow &&
glowRings.map((ring, index) => (
<div
key={index}
className={`absolute inset-0 rounded-full bg-accent-500${ring.opacity} ${ring.blur} ${ring.scale}`}
style={
!isOpen
? {
animation: "radialWave 2s ease-out forwards",
animationDelay: ring.delay,
}
: undefined
}
/>
))}
<button
ref={buttonRef}
onClick={() => setIsOpen(!isOpen)}
className={`relative z-10 w-12 h-12 bg-accent-500 shadow-lg rounded-full border border-accent-600 hover:bg-accent-600 hover:shadow-xl transition-all duration-200 hover:scale-105 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-accent-500 flex items-center justify-center ${
shouldShowGlow ? "scale-105" : ""
}`}
aria-label={t("Theme.changeThemeColors")}
aria-expanded={isOpen}
aria-haspopup="true"
>
<SwatchIcon className="h-6 w-6 text-white" />
</button>
</div>
{/* Theme selection dropdown */}
{isOpen && (
<div
ref={dropdownRef}
className="absolute bottom-16 right-0 bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 p-3 min-w-[200px] animate-in slide-in-from-bottom-2 duration-200"
role="menu"
aria-label={t("Theme.themeOptions")}
>
{/* Color Scheme Section */}
<div className="mb-4">
<div className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3 px-1">
{t("Theme.appearance")}
</div>
<div className="space-y-1">
{Object.entries(COLOR_SCHEMES).map(([schemeKey, schemeInfo]) => {
const scheme = schemeKey as ColorScheme;
const isSelected = theme.colorScheme === scheme;
return (
<button
key={scheme}
onClick={() => handleColorSchemeSelect(scheme)}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors duration-150 ${
isSelected
? "bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-gray-100"
: "text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
}`}
role="menuitem"
aria-current={isSelected ? "true" : "false"}
>
{/* Scheme icon */}
<span
className="text-base flex-shrink-0"
aria-hidden="true"
>
{schemeInfo.icon}
</span>
<span className="flex-1 text-left">
{t(`Theme.schemes.${scheme}`)}
</span>
{/* Show actual system preference for system option */}
{scheme === "system" && (
<span className="text-xs text-gray-500 dark:text-gray-400">
(
{systemPrefersDark ? t("Theme.dark") : t("Theme.light")}
)
</span>
)}
{/* Selected indicator */}
{isSelected && (
<div
className="w-2 h-2 bg-gray-600 dark:bg-gray-400 rounded-full flex-shrink-0"
aria-hidden="true"
/>
)}
</button>
);
})}
</div>
</div>
{/* Divider */}
<div className="border-t border-gray-200 dark:border-gray-700 my-3"></div>
{/* Accent Color Section */}
<div>
<div className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3 px-1">
{t("Theme.accentColor")}
</div>
<div className="space-y-1">
{Object.entries(ACCENT_COLORS).map(([colorKey, colorInfo]) => {
const color = colorKey as AccentColor;
const isSelected = theme.accent === color;
return (
<button
key={color}
onClick={() => handleColorSelect(color)}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors duration-150 ${
isSelected
? "bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-gray-100"
: "text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
}`}
role="menuitem"
aria-current={isSelected ? "true" : "false"}
>
{/* Color preview circle */}
<div
className="w-4 h-4 rounded-full border border-gray-300 dark:border-gray-600 flex-shrink-0"
style={{ backgroundColor: colorInfo.preview }}
aria-hidden="true"
/>
<span className="flex-1 text-left">
{t(`Theme.colors.${color}`)}
</span>
{/* Selected indicator */}
{isSelected && (
<div
className="w-2 h-2 bg-gray-600 dark:bg-gray-400 rounded-full flex-shrink-0"
aria-hidden="true"
/>
)}
</button>
);
})}
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -8,6 +8,6 @@ export const routing = defineRouting({
defaultLocale: "en", defaultLocale: "en",
localeCookie: { localeCookie: {
name: "locale", name: "locale",
maxAge: 60 * 60 * 24 * 365, // 1 year, matching current LanguageSwitcher maxAge: 60 * 60 * 24 * 365, // 1 year, matching current PreferencesMenu
}, },
}); });

View File

@@ -1,6 +1,6 @@
/** /**
* Centralized locale configuration for the application * Centralized locale configuration for the application
* This ensures consistency between LanguageSwitcher and admin translation management * This ensures consistency between PreferencesMenu and admin translation management
*/ */
interface LocaleConfig { interface LocaleConfig {