diff --git a/messages/ui/de.json b/messages/ui/de.json
index e210c93..8a26afb 100644
--- a/messages/ui/de.json
+++ b/messages/ui/de.json
@@ -86,6 +86,11 @@
"dictationServer": "Server (KI)",
"dictationBrowserUnavailable": "In diesem Browser nicht unterstützt"
},
+ "Preferences": {
+ "title": "Einstellungen",
+ "language": "Sprache",
+ "theme": "Design"
+ },
"Common": {
"loading": "Wird geladen…",
"error": "Ein Fehler ist aufgetreten",
diff --git a/messages/ui/en.json b/messages/ui/en.json
index c611de2..9d66db4 100644
--- a/messages/ui/en.json
+++ b/messages/ui/en.json
@@ -86,6 +86,11 @@
"dictationServer": "Server (AI)",
"dictationBrowserUnavailable": "Not supported in this browser"
},
+ "Preferences": {
+ "title": "Preferences",
+ "language": "Language",
+ "theme": "Theme"
+ },
"Common": {
"loading": "Loading…",
"error": "An error occurred",
diff --git a/messages/ui/es.json b/messages/ui/es.json
index 96c9fe0..6d8cbb7 100644
--- a/messages/ui/es.json
+++ b/messages/ui/es.json
@@ -86,6 +86,11 @@
"dictationServer": "Servidor (IA)",
"dictationBrowserUnavailable": "No compatible con este navegador"
},
+ "Preferences": {
+ "title": "Preferencias",
+ "language": "Idioma",
+ "theme": "Tema"
+ },
"Common": {
"loading": "Cargando…",
"error": "Ocurrió un error",
diff --git a/messages/ui/ka.json b/messages/ui/ka.json
index 134a292..149b218 100644
--- a/messages/ui/ka.json
+++ b/messages/ui/ka.json
@@ -86,6 +86,11 @@
"dictationServer": "სერვერი (AI)",
"dictationBrowserUnavailable": "ამ ბრაუზერში არ არის მხარდაჭერილი"
},
+ "Preferences": {
+ "title": "პარამეტრები",
+ "language": "ენა",
+ "theme": "თემა"
+ },
"Common": {
"loading": "იტვირთება…",
"error": "მოხდა შეცდომა",
diff --git a/messages/ui/ru.json b/messages/ui/ru.json
index fb81892..642e29f 100644
--- a/messages/ui/ru.json
+++ b/messages/ui/ru.json
@@ -86,6 +86,11 @@
"dictationServer": "Сервер (ИИ)",
"dictationBrowserUnavailable": "Не поддерживается в этом браузере"
},
+ "Preferences": {
+ "title": "Настройки",
+ "language": "Язык",
+ "theme": "Тема"
+ },
"Common": {
"loading": "Загрузка…",
"error": "Произошла ошибка",
diff --git a/messages/ui/tr.json b/messages/ui/tr.json
index 01b13de..34bc728 100644
--- a/messages/ui/tr.json
+++ b/messages/ui/tr.json
@@ -86,6 +86,11 @@
"dictationServer": "Sunucu (YZ)",
"dictationBrowserUnavailable": "Bu tarayıcıda desteklenmiyor"
},
+ "Preferences": {
+ "title": "Tercihler",
+ "language": "Dil",
+ "theme": "Tema"
+ },
"Common": {
"loading": "Yükleniyor…",
"error": "Bir hata oluştu",
diff --git a/src/app/[locale]/admin/login/page.tsx b/src/app/[locale]/admin/login/page.tsx
index 47a8999..89b2338 100644
--- a/src/app/[locale]/admin/login/page.tsx
+++ b/src/app/[locale]/admin/login/page.tsx
@@ -156,7 +156,7 @@ export default function AdminLogin() {
-
+
{t("Login.title")}
diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx
index 5309389..dd24ca0 100644
--- a/src/app/[locale]/layout.tsx
+++ b/src/app/[locale]/layout.tsx
@@ -4,7 +4,6 @@ import { NextIntlClientProvider } from "next-intl";
import { getMessages, getTranslations } from "next-intl/server";
import { SUPPORTED_LOCALES } from "@/lib/locales";
import { ThemeProvider } from "@/components/theme/ThemeProvider";
-import ThemeSelector from "@/components/theme/ThemeSelector";
import ChatToggle from "@/components/ChatToggle";
import Header from "@/components/Header";
import LocaleHtmlLang from "@/components/LocaleHtmlLang";
@@ -96,7 +95,6 @@ export default async function LocaleLayout({
>
{children}
-
diff --git a/src/components/ChatToggle.tsx b/src/components/ChatToggle.tsx
index b5e2fce..e328f98 100644
--- a/src/components/ChatToggle.tsx
+++ b/src/components/ChatToggle.tsx
@@ -12,7 +12,7 @@ import { supportedLocaleCodes, getLocaleConfig } from "@/lib/locales";
/**
* 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.
*
* Also bridges client-side chat tools (e.g. `setTheme`) to app state
@@ -122,7 +122,7 @@ export default function ChatToggle() {
);
return (
-
+
{/* Bubble button — visible when panel is closed */}
{!isOpen && (
diff --git a/src/components/LanguageSwitcher.tsx b/src/components/LanguageSwitcher.tsx
deleted file mode 100644
index db2675b..0000000
--- a/src/components/LanguageSwitcher.tsx
+++ /dev/null
@@ -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 (
-
-
-
- {isOpen && (
-
-
- {SUPPORTED_LOCALES.map((language) => (
-
- ))}
-
-
- )}
-
- );
-}
diff --git a/src/components/PreferencesMenu.tsx b/src/components/PreferencesMenu.tsx
new file mode 100644
index 0000000..34c86e0
--- /dev/null
+++ b/src/components/PreferencesMenu.tsx
@@ -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
(null);
+ const menuRef = useRef(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 (
+
+ {/* Trigger button */}
+
+
+ {/* Dropdown panel */}
+ {isOpen && (
+
+ {/* ── Language section ─────────────────────────────────── */}
+
+
+
+ {openSection === "language" && (
+
+ {SUPPORTED_LOCALES.map((lang) => {
+ const isSelected = lang.code === locale;
+ return (
+
+ );
+ })}
+
+ )}
+
+
+
+
+ {/* ── Theme section ────────────────────────────────────── */}
+
+
+
+ {openSection === "theme" && (
+
+ {/* Appearance (color scheme) */}
+
+
+ {t("Theme.appearance")}
+
+ {Object.entries(COLOR_SCHEMES).map(([key, info]) => {
+ const scheme = key as ColorScheme;
+ const isSelected = theme.colorScheme === scheme;
+ return (
+
+ );
+ })}
+
+
+ {/* Accent color */}
+
+
+ {t("Theme.accentColor")}
+
+ {Object.entries(ACCENT_COLORS).map(([key, info]) => {
+ const color = key as AccentColor;
+ const isSelected = theme.accent === color;
+ return (
+
+ );
+ })}
+
+
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/src/components/theme/ThemeSelector.tsx b/src/components/theme/ThemeSelector.tsx
deleted file mode 100644
index 763a874..0000000
--- a/src/components/theme/ThemeSelector.tsx
+++ /dev/null
@@ -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(null);
- const buttonRef = useRef(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 (
-
- {/* Theme selector button with unified radial glow system */}
-
- {/* Unified glow rings - permanent when open, animated when pulsing */}
- {shouldShowGlow &&
- glowRings.map((ring, index) => (
-
- ))}
-
-
-
-
- {/* Theme selection dropdown */}
- {isOpen && (
-
- {/* Color Scheme Section */}
-
-
- {t("Theme.appearance")}
-
-
-
- {Object.entries(COLOR_SCHEMES).map(([schemeKey, schemeInfo]) => {
- const scheme = schemeKey as ColorScheme;
- const isSelected = theme.colorScheme === scheme;
-
- return (
-
- );
- })}
-
-
-
- {/* Divider */}
-
-
- {/* Accent Color Section */}
-
-
- {t("Theme.accentColor")}
-
-
-
- {Object.entries(ACCENT_COLORS).map(([colorKey, colorInfo]) => {
- const color = colorKey as AccentColor;
- const isSelected = theme.accent === color;
-
- return (
-
- );
- })}
-
-
-
- )}
-
- );
-}
diff --git a/src/i18n/routing.ts b/src/i18n/routing.ts
index e3557a8..72aee79 100644
--- a/src/i18n/routing.ts
+++ b/src/i18n/routing.ts
@@ -8,6 +8,6 @@ export const routing = defineRouting({
defaultLocale: "en",
localeCookie: {
name: "locale",
- maxAge: 60 * 60 * 24 * 365, // 1 year, matching current LanguageSwitcher
+ maxAge: 60 * 60 * 24 * 365, // 1 year, matching current PreferencesMenu
},
});
diff --git a/src/lib/locales.ts b/src/lib/locales.ts
index 7431375..55fb82f 100644
--- a/src/lib/locales.ts
+++ b/src/lib/locales.ts
@@ -1,6 +1,6 @@
/**
* 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 {