diff --git a/src/app/[lang]/layout.tsx b/src/app/[lang]/layout.tsx index f61f7a7..d059d0a 100644 --- a/src/app/[lang]/layout.tsx +++ b/src/app/[lang]/layout.tsx @@ -1,8 +1,10 @@ import type { Metadata } from 'next'; +import { cookies } from 'next/headers'; import { notFound } from 'next/navigation'; import { NextIntlClientProvider } from 'next-intl'; import { getMessages } from 'next-intl/server'; import { RouteProgressProvider } from '@/features/Navigation/RouteProgress'; +import { resolveTheme, THEME_COOKIE, ThemeProvider } from '@/features/Theme'; import { locales } from '@/utils/locales'; import '../globals.css'; @@ -27,12 +29,17 @@ export default async function RootLayout({ const messages = await getMessages({ locale: lang }); + const cookieStore = await cookies(); + const theme = resolveTheme(cookieStore.get(THEME_COOKIE)?.value); + return ( - + - - {children} - + + + {children} + + ); diff --git a/src/app/[lang]/settings/SettingsRouteClient.tsx b/src/app/[lang]/settings/SettingsRouteClient.tsx index 7889246..fe2ddac 100644 --- a/src/app/[lang]/settings/SettingsRouteClient.tsx +++ b/src/app/[lang]/settings/SettingsRouteClient.tsx @@ -5,6 +5,7 @@ import { type SettingsFormValues, SettingsPage, } from '@/features/Settings/SettingsPage'; +import { locales } from '@/utils/locales'; type SettingsRouteClientProps = { defaultEmail: string; @@ -28,7 +29,7 @@ type SettingsRouteClientProps = { userDisplayName: string; }; -const saveSettings = async (data: SettingsFormValues) => { +const postSettings = async (data: SettingsFormValues) => { const response = await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -40,6 +41,9 @@ const saveSettings = async (data: SettingsFormValues) => { } }; +const isSupportedLocale = (value: string): value is (typeof locales)[number] => + locales.includes(value as (typeof locales)[number]); + const downloadAccountData = async () => { const response = await fetch('/api/account/export'); @@ -67,6 +71,19 @@ export const SettingsRouteClient = ({ userDisplayName, }: SettingsRouteClientProps) => { const router = useRouteProgressRouter(); + + const saveSettings = async (data: SettingsFormValues) => { + await postSettings(data); + + if ( + isSupportedLocale(data.applicationLanguage) && + data.applicationLanguage !== locale + ) { + router.push(`/${data.applicationLanguage}/settings`); + router.refresh(); + } + }; + const defaultSettings: SettingsFormValues = { isPublic: initialPrivacySettings?.isPublic ?? true, allowAnonymousCopy: initialPrivacySettings?.allowAnonymousCopy ?? true, diff --git a/src/app/api/auth/discord/callback/route.ts b/src/app/api/auth/discord/callback/route.ts index ba602e5..20ef868 100644 --- a/src/app/api/auth/discord/callback/route.ts +++ b/src/app/api/auth/discord/callback/route.ts @@ -1,5 +1,6 @@ import { type NextRequest, NextResponse } from 'next/server'; -import { upsertDiscordUser } from '@/db'; +import { getUserSettingsByDiscordUserId, upsertDiscordUser } from '@/db'; +import { resolveTheme, THEME_COOKIE } from '@/features/Theme'; import { AUTH_ERROR_PARAM, clearOAuthStateCookie, @@ -7,6 +8,26 @@ import { readOAuthStateCookie, setSessionCookie, } from '@/lib/auth'; +import { locales } from '@/utils/locales'; + +const THEME_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; + +const isSupportedLocale = (value: string): value is (typeof locales)[number] => + locales.includes(value as (typeof locales)[number]); + +const applyPreferredLocale = (redirectTo: string, preferred: string) => { + if (!isSupportedLocale(preferred)) { + return redirectTo; + } + + const segments = redirectTo.split('/'); + if (segments[1] && isSupportedLocale(segments[1])) { + segments[1] = preferred; + return segments.join('/'); + } + + return `/${preferred}${redirectTo}`; +}; type DiscordTokenResponse = { access_token?: string; @@ -112,12 +133,25 @@ export const GET = async (request: NextRequest) => { const currentUser = normalizeDiscordUser(discordUser); await upsertDiscordUser(currentUser); + const settings = await getUserSettingsByDiscordUserId(currentUser.id); + const destination = settings + ? applyPreferredLocale(redirectTo, settings.applicationLanguage) + : redirectTo; + const response = NextResponse.redirect( - new URL(redirectTo, request.nextUrl.origin), + new URL(destination, request.nextUrl.origin), ); await setSessionCookie(response, currentUser); clearOAuthStateCookie(response); + if (settings) { + response.cookies.set(THEME_COOKIE, resolveTheme(settings.theme), { + path: '/', + maxAge: THEME_COOKIE_MAX_AGE, + sameSite: 'lax', + }); + } + return response; } catch { return redirectWithError(request, redirectTo, 'oauth_failed'); diff --git a/src/app/globals.css b/src/app/globals.css index 35f6637..ec233e1 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -41,6 +41,41 @@ --font-DM: "DM Sans", sans-serif; } +html[data-theme="light"] { + --color-primary: #2c3a49; + --color-primary-light: #1d2731; + --color-primary-lighter: #0f161d; + --color-primary-dark: #5b6b7b; + --color-primary-darker: #d9e3ee; + + --color-background-dark: #ffffff; + --color-background-light: #6b7280; + --color-background-darker: #eef1f5; + --color-background-main: #f3f5f8; + --color-background-lighter: #9aa3ae; + --color-background-darkest: #e2e6ec; +} + +html[data-theme="light"] body { + color: #1a1d22; +} + +html[data-theme="light"] .text-white { + color: #1a1d22; +} + +html[data-theme="light"] .border-white\/10 { + border-color: rgba(0, 0, 0, 0.08); +} + +html[data-theme="light"] .bg-white\/10 { + background-color: rgba(0, 0, 0, 0.06); +} + +html[data-theme="light"] .hover\:bg-\[\#161617\]:hover { + background-color: #e7eaf0; +} + @keyframes slideDown { from { transform: translateY(-10px); diff --git a/src/features/Settings/SettingsPage.tsx b/src/features/Settings/SettingsPage.tsx index 5bd7a04..f9c6699 100644 --- a/src/features/Settings/SettingsPage.tsx +++ b/src/features/Settings/SettingsPage.tsx @@ -16,7 +16,9 @@ import { TextInput, Toggle, } from '@/components/Form'; -import { languageOptions, type TimeFormat } from '@/constants/languages'; +import { getLanguageName, type TimeFormat } from '@/constants/languages'; +import { useTheme } from '@/features/Theme'; +import { locales } from '@/utils/locales'; import { CompareTable } from './CompareTable'; import { type SettingsFormValues, settingsSchema } from './schema'; @@ -108,6 +110,7 @@ export const SettingsPage: React.FC = ({ }) => { const t = useTranslations('Settings'); const currentLocale = useLocale(); + const { theme: committedTheme, setTheme, previewTheme } = useTheme(); const [activeSection, setActiveSection] = useState('account'); const [exportStatus, setExportStatus] = useState< 'idle' | 'loading' | 'success' | 'error' @@ -121,9 +124,10 @@ export const SettingsPage: React.FC = ({ const initialValues: SettingsFormValues = useMemo( () => ({ ...defaultValues, + theme: committedTheme, timeFormat: defaultValues.timeFormat || getStoredTimeFormat(), }), - [defaultValues], + [defaultValues, committedTheme], ); const { @@ -162,6 +166,7 @@ export const SettingsPage: React.FC = ({ window.dispatchEvent(new Event('timeFormatChanged')); } + setTheme(data.theme); reset(data); } catch { setSaveStatus('error'); @@ -176,6 +181,7 @@ export const SettingsPage: React.FC = ({ const handleDiscard = () => { reset(); + previewTheme(committedTheme); setExportStatus('idle'); setDeleteStatus('idle'); setSaveStatus('idle'); @@ -211,9 +217,9 @@ export const SettingsPage: React.FC = ({ const localizedLanguageOptions = useMemo( () => - languageOptions(currentLocale).map((option) => ({ - ...option, - label: `${option.label} (${option.value.toUpperCase()})`, + locales.map((locale) => ({ + value: locale, + label: `${getLanguageName(locale, currentLocale)} (${locale.toUpperCase()})`, })), [currentLocale], ); @@ -537,7 +543,10 @@ export const SettingsPage: React.FC = ({ render={({ field }) => (