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 {