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 }) => (