import {useEffect, useState} from 'react'; import {useNavigate} from 'react-router-dom'; import {useTranslation} from 'react-i18next'; import {useForm} from 'react-hook-form'; import {zodResolver} from '@hookform/resolvers/zod'; import {Copy, Gamepad2, ImageOff, Loader2, Palette, RotateCcw, Save, SlidersHorizontal, UserRound} from 'lucide-react'; import {toast} from 'sonner'; import {Button} from '@/components/ui/button'; import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'; import {Input} from '@/components/ui/input'; import {Label} from '@/components/ui/label'; import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select'; import {Skeleton} from '@/components/ui/skeleton'; import {Switch} from '@/components/ui/switch'; import FormField from '../components/FormField.jsx'; import {settingsSchema} from '../utils/validation.js'; import {usePreferences} from '../context/PreferencesContext.jsx'; import useSessionExpired from '../hooks/useSessionExpired.js'; import useSaveAppearance from '../hooks/useSaveAppearance.js'; import {ACCENTS, CORNERS} from '../utils/appearance.js'; import {coverUrl} from '../utils/games.js'; import {cn} from '@/lib/utils'; import {apiFetch} from '../utils/api.js'; import {hasSession, markSession} from '../utils/auth.js'; const BACKEND_ERROR_KEY = { STEAM_ID_ALREADY_IN_USE: 'REGISTRATION.ERROR_STEAM_ID_IN_USE', DISPLAY_NAME_ALREADY_IN_USE: 'SETTINGS.ERROR_DISPLAY_NAME_IN_USE', }; const SUPPORTED_LANGUAGES = ['en', 'de']; const formatDate = (value, locale) => { if (!value) return '—'; try { return new Date(value).toLocaleString(locale); } catch { return value; } }; export default function SettingsPage() { const {t, i18n} = useTranslation(); const navigate = useNavigate(); const expireSession = useSessionExpired(); const {isDarkMode, setDarkMode, setLanguage, appearance} = usePreferences(); const saveAppearance = useSaveAppearance(); const [profile, setProfile] = useState(null); const [isLoading, setIsLoading] = useState(true); const [loadError, setLoadError] = useState(null); const { register, handleSubmit, reset, formState: {errors, isSubmitting}, } = useForm({ resolver: zodResolver(settingsSchema), defaultValues: {useDarkTheme: true, language: 'en', steamId: '', displayName: ''}, }); useEffect(() => { if (!hasSession()) { navigate('/login'); return; } const load = async () => { try { const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, { method: 'GET', headers: { 'Content-Type': 'application/json', }, }); if (response.status === 401) { expireSession(); return; } if (!response.ok) { setLoadError(t('INFO.ERROR.LOAD_PROFILE')); return; } const data = await response.json(); setProfile(data); reset({ useDarkTheme: !!data.settings?.useDarkTheme, language: data.settings?.language || 'en', steamId: data.steamId ?? '', displayName: data.displayName ?? '', }); } catch (err) { console.error('Settings load error:', err); setLoadError(t('INFO.ERROR.NETWORK_FAILURE')); } finally { setIsLoading(false); } }; load(); }, [navigate, expireSession, reset, t]); const persistSettings = async (payload) => { if (!hasSession()) { navigate('/login'); return {ok: false}; } try { const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/settings`, { method: 'PUT', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(payload), }); if (response.status === 401) { expireSession(); return {ok: false}; } if (!response.ok) { const data = await response.json().catch(() => ({})); const mappedKey = BACKEND_ERROR_KEY[data.code]; toast.error(mappedKey ? t(mappedKey) : data.message || t('SETTINGS.ERROR_SAVE')); return {ok: false}; } // Auth set a new access cookie for the new Steam ID; the new stamp makes the header load it again. markSession(); toast.success(t('SETTINGS.SAVED')); return {ok: true}; } catch (err) { console.error('Settings save error:', err); toast.error(t('INFO.ERROR.NETWORK_FAILURE')); return {ok: false}; } }; // The backend replaces all settings, so every save sends the saved values of the fields it does not change. const savedSettings = () => ({ useDarkTheme: isDarkMode, language: i18n.language, steamId: profile?.steamId ?? null, displayName: profile?.displayName ?? null, publicProfile: !!profile?.settings?.publicProfile, }); const handleDarkThemeToggle = async () => { const next = !isDarkMode; setDarkMode(next); // apply on the fly await persistSettings({...savedSettings(), useDarkTheme: next}); }; const handleLanguageChange = async (next) => { setLanguage(next); // apply on the fly await persistSettings({...savedSettings(), language: next}); }; const handlePublicProfileToggle = async () => { const next = !profile?.settings?.publicProfile; const result = await persistSettings({...savedSettings(), publicProfile: next}); if (result.ok) { setProfile((p) => ({...p, settings: {...p.settings, publicProfile: next}})); } }; const copyProfileLink = async (link) => { try { await navigator.clipboard.writeText(link); toast.success(t('SETTINGS.PUBLIC_PROFILE_COPIED')); } catch { toast.error(link); } }; const onSubmitProfile = async (values) => { // Without a name the profile has no link, so removing the name also makes it private. const publicProfile = savedSettings().publicProfile && !!values.displayName; const result = await persistSettings({ ...savedSettings(), steamId: values.steamId, displayName: values.displayName, publicProfile, }); if (result.ok) { setProfile((p) => (p ? { ...p, steamId: values.steamId, displayName: values.displayName, settings: {...p.settings, publicProfile}, } : p)); } }; if (isLoading) { return (
{t('SETTINGS.WIDE_LAYOUT_HINT')}
{t('SETTINGS.BACKGROUND_HINT')}
)}{t('SETTINGS.BANNER_HINT')}
)}{profileLink ? t('SETTINGS.PUBLIC_PROFILE_HINT') : t('SETTINGS.PUBLIC_PROFILE_NEEDS_NAME')}