diff --git a/messages/en.json b/messages/en.json new file mode 100644 index 000000000..5f6034ba0 --- /dev/null +++ b/messages/en.json @@ -0,0 +1,119 @@ +{ + "common": { + "appName": "DevTrack", + "save": "Save", + "saving": "Saving...", + "reset": "Reset", + "remove": "Remove", + "removing": "Removing...", + "loading": "Loading...", + "settings": "Settings", + "dashboard": "Dashboard", + "signIn": "Sign in", + "signOut": "Sign out", + "backToHome": "Back to home", + "backToDashboard": "Back to Dashboard", + "cancel": "Cancel", + "language": "Language" + }, + "navigation": { + "main": "Main navigation", + "overview": "Overview", + "resume": "Resume", + "leaderboard": "Leaderboard", + "home": "Home", + "features": "Features", + "openMenu": "Open navigation menu", + "closeMenu": "Close navigation menu", + "loggedInAs": "Logged in as", + "settings": "Settings", + "signInCta": "SIGN IN", + "signOutCta": "Sign out" + }, + "dashboard": { + "overviewEyebrow": "Dashboard overview", + "title": "Dashboard", + "subtitle": "coding activity at a glance", + "welcomeBack": "Welcome back", + "goodMorning": "Good morning", + "goodAfternoon": "Good afternoon", + "goodEvening": "Good evening", + "midnightOil": "Burning the midnight oil", + "syncedJustNow": "Synced just now", + "syncedMinutesAgo": "Synced {minutes} min ago", + "shareProfile": "Share Profile", + "viewPublicProfile": "View your public profile", + "yearInCode": "Year in Code", + "newFeature": "New Feature", + "resumeGenerator": "Resume Generator", + "resumeHeadline": "Generate an ATS-Friendly CV Backed by Your Real Code", + "resumeDescription": "Analyze your GitHub contributions, merged PRs, and lines of code changed to automatically generate professional bullet points for your target roles.", + "buildResume": "Build Resume", + "nightOwl": "Night Owl", + "earlyBird": "Early Bird", + "nightOwlTitle": "Night Owl Milestone: You push code between Midnight and 4 AM!", + "earlyBirdTitle": "Early Bird Milestone: You push code between 5 AM and 8 AM!", + "profileLinkCopied": "Profile link copied!", + "profileLinkCopyFailed": "Failed to copy link" + }, + "auth": { + "signInFailed": "Sign-in failed", + "githubError": "GitHub sign-in failed. This is usually caused by incorrect OAuth credentials or a mismatched callback URL. Check your GitHub OAuth App settings and try again.", + "oauthCallbackError": "The OAuth callback could not be completed. Please try signing in again.", + "oauthSigninError": "Could not start the GitHub sign-in flow. Please try again.", + "configurationError": "There is a server configuration error. Please contact the site administrator.", + "accessDeniedError": "Access was denied. You may have cancelled the GitHub authorization.", + "verificationError": "The sign-in link has expired or has already been used.", + "defaultError": "An unexpected authentication error occurred. Please try again.", + "welcome": "WELCOME", + "back": "BACK.", + "tagline": "Track streaks, PR velocity & coding growth.", + "signInWithGitHub": "Sign in with GitHub", + "licenseLine": "MIT License · Self-hostable · Free forever" + }, + "settings": { + "title": "Settings", + "loadingLabel": "Loading settings", + "languageTitle": "Language", + "languageDescription": "Choose the display language used across DevTrack.", + "languageSelectLabel": "Display language", + "languageSaved": "Language preference saved", + "languageSaveFailed": "Failed to save language preference", + "profileTitle": "Profile Settings", + "profileDescription": "Manage your public profile, dashboard preferences, and integrations.", + "appearanceTitle": "Appearance", + "appearanceDescription": "Choose the theme that matches your workflow.", + "weeklyDigestTitle": "Weekly Email Digest", + "weeklyDigestDescription": "Receive an optional weekly email digest every Monday morning summarizing your coding habits.", + "notificationsTitle": "Notifications", + "notificationsDescription": "Send a weekly summary of your activity to Slack or Discord via webhook.", + "webhookUrl": "Webhook URL", + "connectedAccountsTitle": "Connected Accounts", + "connectedAccountsDescription": "Link additional GitHub accounts and switch between them on the dashboard.", + "addGitHubAccount": "Add GitHub Account", + "noLinkedAccounts": "No linked GitHub accounts yet.", + "loadingLinkedAccounts": "Loading linked accounts...", + "wakatimeTitle": "Wakatime Integration", + "wakatimeDescription": "Connect your Wakatime account to display accurate coding time and language usage.", + "apiKey": "API Key", + "discordTitle": "Discord Integration", + "discordDescription": "Receive streak reminders and milestone alerts in your Discord server.", + "timezoneLabel": "Timezone (For 8 PM reminders)", + "saveDiscord": "Save Discord Settings", + "testNotification": "Test Notification", + "testing": "Testing...", + "muteNotifications": "Mute Notifications", + "unmuteNow": "Unmute Now", + "unsavedTitle": "Unsaved Changes", + "unsavedMessage": "You have unsaved changes in your settings. If you leave now, your progress will be lost.", + "leaveAnyway": "Leave Anyway", + "stayAndSave": "Stay and Save" + }, + "achievements": { + "title": "GitHub Achievements", + "loading": "Loading GitHub achievements", + "loadFailed": "GitHub achievements could not be loaded right now.", + "empty": "No public GitHub achievements available yet.", + "badgeAlt": "{title} GitHub achievement badge" + } +} diff --git a/messages/es.json b/messages/es.json new file mode 100644 index 000000000..5daaf19c8 --- /dev/null +++ b/messages/es.json @@ -0,0 +1,119 @@ +{ + "common": { + "appName": "DevTrack", + "save": "Guardar", + "saving": "Guardando...", + "reset": "Restablecer", + "remove": "Eliminar", + "removing": "Eliminando...", + "loading": "Cargando...", + "settings": "Configuración", + "dashboard": "Panel", + "signIn": "Iniciar sesión", + "signOut": "Cerrar sesión", + "backToHome": "Volver al inicio", + "backToDashboard": "Volver al panel", + "cancel": "Cancelar", + "language": "Idioma" + }, + "navigation": { + "main": "Navegación principal", + "overview": "Resumen", + "resume": "CV", + "leaderboard": "Clasificación", + "home": "Inicio", + "features": "Funciones", + "openMenu": "Abrir menú de navegación", + "closeMenu": "Cerrar menú de navegación", + "loggedInAs": "Sesión iniciada como", + "settings": "Configuración", + "signInCta": "INICIAR SESIÓN", + "signOutCta": "Cerrar sesión" + }, + "dashboard": { + "overviewEyebrow": "Resumen del panel", + "title": "Panel", + "subtitle": "actividad de código de un vistazo", + "welcomeBack": "Te damos la bienvenida", + "goodMorning": "Buenos días", + "goodAfternoon": "Buenas tardes", + "goodEvening": "Buenas noches", + "midnightOil": "Trabajando de madrugada", + "syncedJustNow": "Sincronizado ahora mismo", + "syncedMinutesAgo": "Sincronizado hace {minutes} min", + "shareProfile": "Compartir perfil", + "viewPublicProfile": "Ver tu perfil público", + "yearInCode": "Año en código", + "newFeature": "Nueva función", + "resumeGenerator": "Generador de CV", + "resumeHeadline": "Genera un CV compatible con ATS basado en tu código real", + "resumeDescription": "Analiza tus contribuciones en GitHub, PR fusionadas y líneas de código modificadas para generar automáticamente viñetas profesionales para tus puestos objetivo.", + "buildResume": "Crear CV", + "nightOwl": "Noctámbulo", + "earlyBird": "Madrugador", + "nightOwlTitle": "Logro noctámbulo: subes código entre la medianoche y las 4 a. m.", + "earlyBirdTitle": "Logro madrugador: subes código entre las 5 a. m. y las 8 a. m.", + "profileLinkCopied": "Enlace del perfil copiado.", + "profileLinkCopyFailed": "No se pudo copiar el enlace" + }, + "auth": { + "signInFailed": "Error al iniciar sesión", + "githubError": "El inicio de sesión con GitHub falló. Normalmente esto se debe a credenciales OAuth incorrectas o a una URL de devolución que no coincide. Revisa la configuración de tu aplicación OAuth de GitHub e inténtalo de nuevo.", + "oauthCallbackError": "No se pudo completar la devolución OAuth. Intenta iniciar sesión de nuevo.", + "oauthSigninError": "No se pudo iniciar el flujo de acceso con GitHub. Inténtalo de nuevo.", + "configurationError": "Hay un error de configuración del servidor. Contacta con el administrador del sitio.", + "accessDeniedError": "Acceso denegado. Puede que hayas cancelado la autorización de GitHub.", + "verificationError": "El enlace de inicio de sesión caducó o ya se usó.", + "defaultError": "Ocurrió un error de autenticación inesperado. Inténtalo de nuevo.", + "welcome": "HOLA DE", + "back": "NUEVO.", + "tagline": "Sigue rachas, velocidad de PR y crecimiento de código.", + "signInWithGitHub": "Iniciar sesión con GitHub", + "licenseLine": "Licencia MIT · Autoalojable · Gratis para siempre" + }, + "settings": { + "title": "Configuración", + "loadingLabel": "Cargando configuración", + "languageTitle": "Idioma", + "languageDescription": "Elige el idioma de visualización usado en DevTrack.", + "languageSelectLabel": "Idioma de visualización", + "languageSaved": "Preferencia de idioma guardada", + "languageSaveFailed": "No se pudo guardar la preferencia de idioma", + "profileTitle": "Configuración del perfil", + "profileDescription": "Gestiona tu perfil público, preferencias del panel e integraciones.", + "appearanceTitle": "Apariencia", + "appearanceDescription": "Elige el tema que se adapta a tu flujo de trabajo.", + "weeklyDigestTitle": "Resumen semanal por correo", + "weeklyDigestDescription": "Recibe un resumen semanal opcional cada lunes por la mañana con tus hábitos de programación.", + "notificationsTitle": "Notificaciones", + "notificationsDescription": "Envía un resumen semanal de tu actividad a Slack o Discord mediante webhook.", + "webhookUrl": "URL del webhook", + "connectedAccountsTitle": "Cuentas conectadas", + "connectedAccountsDescription": "Vincula cuentas adicionales de GitHub y cambia entre ellas en el panel.", + "addGitHubAccount": "Agregar cuenta de GitHub", + "noLinkedAccounts": "Aún no hay cuentas de GitHub vinculadas.", + "loadingLinkedAccounts": "Cargando cuentas vinculadas...", + "wakatimeTitle": "Integración con Wakatime", + "wakatimeDescription": "Conecta tu cuenta de Wakatime para mostrar tiempo de programación y uso de lenguajes con precisión.", + "apiKey": "Clave API", + "discordTitle": "Integración con Discord", + "discordDescription": "Recibe recordatorios de rachas y alertas de hitos en tu servidor de Discord.", + "timezoneLabel": "Zona horaria (para recordatorios de las 8 p. m.)", + "saveDiscord": "Guardar configuración de Discord", + "testNotification": "Probar notificación", + "testing": "Probando...", + "muteNotifications": "Silenciar notificaciones", + "unmuteNow": "Quitar silencio ahora", + "unsavedTitle": "Cambios sin guardar", + "unsavedMessage": "Tienes cambios sin guardar en tu configuración. Si sales ahora, perderás tu progreso.", + "leaveAnyway": "Salir de todos modos", + "stayAndSave": "Quedarse y guardar" + }, + "achievements": { + "title": "Logros de GitHub", + "loading": "Cargando logros de GitHub", + "loadFailed": "Los logros de GitHub no se pudieron cargar en este momento.", + "empty": "Aún no hay logros públicos de GitHub disponibles.", + "badgeAlt": "Insignia del logro de GitHub {title}" + } +} diff --git a/next.config.mjs b/next.config.mjs index 8e4dc1773..ad1bfb394 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,4 +1,7 @@ import withPWAInit from "@ducanh2912/next-pwa"; +import createNextIntlPlugin from "next-intl/plugin"; + +const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts"); const withPWA = withPWAInit({ dest: "public", @@ -195,4 +198,4 @@ const nextConfig = { }, }; -export default withPWA(nextConfig); +export default withNextIntl(withPWA(nextConfig)); diff --git a/package-lock.json b/package-lock.json index 5c52af4b5..52a48e0b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,7 +42,8 @@ "sharp": "^0.34.5", "sonner": "^2.0.7", "swagger-ui-react": "^5.32.6", - "tailwind-merge": "^3.6.0" + "tailwind-merge": "^3.6.0", + "next-intl": "3.26.5" }, "devDependencies": { "@next/env": "^16.2.7", @@ -20518,6 +20519,121 @@ "type": "github", "url": "https://github.com/sponsors/wooorm" } + }, + "node_modules/@formatjs/ecma402-abstract": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz", + "integrity": "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==", + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "2.2.7", + "@formatjs/intl-localematcher": "0.6.2", + "decimal.js": "^10.4.3", + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/ecma402-abstract/node_modules/@formatjs/intl-localematcher": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.2.tgz", + "integrity": "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/fast-memoize": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz", + "integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/icu-messageformat-parser": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.4.tgz", + "integrity": "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==", + "license": "MIT", + "dependencies": { + "@formatjs/ecma402-abstract": "2.3.6", + "@formatjs/icu-skeleton-parser": "1.8.16", + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/icu-skeleton-parser": { + "version": "1.8.16", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.16.tgz", + "integrity": "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==", + "license": "MIT", + "dependencies": { + "@formatjs/ecma402-abstract": "2.3.6", + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/intl-localematcher": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.5.10.tgz", + "integrity": "sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q==", + "license": "MIT", + "dependencies": { + "tslib": "2" + } + }, + "node_modules/intl-messageformat": { + "version": "10.7.18", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.18.tgz", + "integrity": "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==", + "license": "BSD-3-Clause", + "dependencies": { + "@formatjs/ecma402-abstract": "2.3.6", + "@formatjs/fast-memoize": "2.2.7", + "@formatjs/icu-messageformat-parser": "2.11.4", + "tslib": "^2.8.0" + } + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/next-intl": { + "version": "3.26.5", + "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-3.26.5.tgz", + "integrity": "sha512-EQlCIfY0jOhRldiFxwSXG+ImwkQtDEfQeSOEQp6ieAGSLWGlgjdb/Ck/O7wMfC430ZHGeUKVKax8KGusTPKCgg==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/amannn" + } + ], + "license": "MIT", + "dependencies": { + "@formatjs/intl-localematcher": "^0.5.4", + "negotiator": "^1.0.0", + "use-intl": "^3.26.5" + }, + "peerDependencies": { + "next": "^10.0.0 || ^11.0.0 || ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" + } + }, + "node_modules/use-intl": { + "version": "3.26.5", + "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-3.26.5.tgz", + "integrity": "sha512-OdsJnC/znPvHCHLQH/duvQNXnP1w0hPfS+tkSi3mAbfjYBGh4JnyfdwkQBfIVf7t8gs9eSX/CntxUMvtKdG2MQ==", + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "^2.2.0", + "intl-messageformat": "^10.5.14" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" + } } } } diff --git a/package.json b/package.json index e4d89fcd6..6076d9336 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "lucide-react": "^0.475.0", "next": "14.2.35", "next-auth": "^4.24.14", + "next-intl": "3.26.5", "next-pwa": "^5.6.0", "qrcode.react": "^4.2.0", "react": "^18", diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml deleted file mode 100644 index 8c1f0c935..000000000 --- a/pnpm-workspace.yaml +++ /dev/null @@ -1,4 +0,0 @@ -allowBuilds: - core-js: set this to true or false - esbuild: set this to true or false - unrs-resolver: set this to true or false diff --git a/src/app/api/user/settings/route.ts b/src/app/api/user/settings/route.ts index 572e8e14e..409c019c5 100644 --- a/src/app/api/user/settings/route.ts +++ b/src/app/api/user/settings/route.ts @@ -7,14 +7,36 @@ import { encryptToken } from "@/lib/crypto"; import { validateTextInput } from "@/lib/sanitize"; import { clearLeaderboardCache } from "@/lib/leaderboard"; import { cacheGet, cacheSet, cacheDelete } from "@/lib/metrics-cache"; +import { + defaultLocale, + isValidLocale, + localeCookieMaxAge, + localeCookieName, +} from "@/i18n/config"; export const dynamic = "force-dynamic"; +function settingsResponse(body: Record, status = 200) { + const response = NextResponse.json(body, { status }); + const preferredLocale = + typeof body.preferred_locale === "string" && isValidLocale(body.preferred_locale) + ? body.preferred_locale + : defaultLocale; + + response.cookies.set(localeCookieName, preferredLocale, { + maxAge: localeCookieMaxAge, + path: "/", + sameSite: "lax", + }); + + return response; +} + async function fetchUserSettings(userId: string) { // Tier 1: All columns const res1 = await supabaseAdmin .from("users") - .select("id, github_login, bio, is_public, public_since, show_weekly_goals, leaderboard_opt_in, pinned_repos, wakatime_api_key_encrypted, wakatime_api_key_iv, weekly_digest_opt_in, discord_webhook_url, timezone, webhook_url, discord_muted_until") + .select("id, github_login, bio, is_public, public_since, show_weekly_goals, leaderboard_opt_in, pinned_repos, wakatime_api_key_encrypted, wakatime_api_key_iv, weekly_digest_opt_in, discord_webhook_url, timezone, webhook_url, discord_muted_until, preferred_locale") .eq("id", userId) .single(); @@ -30,6 +52,7 @@ async function fetchUserSettings(userId: string) { hasBio: true, hasWebhookUrl: true, hasDiscordMutedUntil: true, + hasPreferredLocale: true, leaderboard_opt_in: (res1.data as any).leaderboard_opt_in ?? false, weekly_digest_opt_in: (res1.data as any).weekly_digest_opt_in ?? false, pinned_repos: (res1.data as any).pinned_repos || [], @@ -39,6 +62,7 @@ async function fetchUserSettings(userId: string) { timezone: (res1.data as any).timezone || "UTC", webhook_url: (res1.data as any).webhook_url || null, discord_muted_until: (res1.data as any).discord_muted_until || null, + preferred_locale: (res1.data as any).preferred_locale || defaultLocale, }; } @@ -54,6 +78,7 @@ async function fetchUserSettings(userId: string) { hasBio: false, hasWebhookUrl: false, hasDiscordMutedUntil: false, + hasPreferredLocale: false, leaderboard_opt_in: false, weekly_digest_opt_in: false, pinned_repos: [] as string[], @@ -63,6 +88,7 @@ async function fetchUserSettings(userId: string) { timezone: "UTC", webhook_url: null, discord_muted_until: null, + preferred_locale: defaultLocale, }; } @@ -85,6 +111,7 @@ async function fetchUserSettings(userId: string) { hasBio: false, hasWebhookUrl: true, hasDiscordMutedUntil: false, + hasPreferredLocale: false, leaderboard_opt_in: (res2.data as any).leaderboard_opt_in ?? false, weekly_digest_opt_in: false, pinned_repos: (res2.data as any).pinned_repos || [], @@ -94,6 +121,7 @@ async function fetchUserSettings(userId: string) { timezone: "UTC", webhook_url: (res2.data as any).webhook_url || null, discord_muted_until: null, + preferred_locale: defaultLocale, }; } @@ -109,6 +137,7 @@ async function fetchUserSettings(userId: string) { hasBio: false, hasWebhookUrl: false, hasDiscordMutedUntil: false, + hasPreferredLocale: false, leaderboard_opt_in: false, weekly_digest_opt_in: false, pinned_repos: [] as string[], @@ -118,6 +147,7 @@ async function fetchUserSettings(userId: string) { timezone: "UTC", webhook_url: null, discord_muted_until: null, + preferred_locale: defaultLocale, }; } @@ -139,6 +169,7 @@ async function fetchUserSettings(userId: string) { hasDiscordSettings: false, hasBio: false, hasDiscordMutedUntil: false, + hasPreferredLocale: false, leaderboard_opt_in: false, weekly_digest_opt_in: false, pinned_repos: [] as string[], @@ -147,6 +178,7 @@ async function fetchUserSettings(userId: string) { discord_webhook_url: null, timezone: "UTC", discord_muted_until: null, + preferred_locale: defaultLocale, }; } @@ -161,6 +193,7 @@ async function fetchUserSettings(userId: string) { hasDiscordSettings: false, hasBio: false, hasDiscordMutedUntil: false, + hasPreferredLocale: false, leaderboard_opt_in: false, weekly_digest_opt_in: false, pinned_repos: [] as string[], @@ -169,6 +202,7 @@ async function fetchUserSettings(userId: string) { discord_webhook_url: null, timezone: "UTC", discord_muted_until: null, + preferred_locale: defaultLocale, }; } @@ -190,6 +224,7 @@ async function fetchUserSettings(userId: string) { hasDiscordSettings: false, hasBio: false, hasDiscordMutedUntil: false, + hasPreferredLocale: false, leaderboard_opt_in: false, weekly_digest_opt_in: false, pinned_repos: [] as string[], @@ -198,6 +233,7 @@ async function fetchUserSettings(userId: string) { discord_webhook_url: null, timezone: "UTC", discord_muted_until: null, + preferred_locale: defaultLocale, }; } @@ -211,6 +247,7 @@ async function fetchUserSettings(userId: string) { hasDiscordSettings: false, hasBio: false, hasDiscordMutedUntil: false, + hasPreferredLocale: false, leaderboard_opt_in: false, weekly_digest_opt_in: false, pinned_repos: [] as string[], @@ -219,6 +256,7 @@ async function fetchUserSettings(userId: string) { discord_webhook_url: null, timezone: "UTC", discord_muted_until: null, + preferred_locale: defaultLocale, }; } @@ -242,7 +280,7 @@ export async function GET(req: NextRequest) { const cached = await cacheGet>(cacheKey, SETTINGS_TTL); if (cached) { - return NextResponse.json(cached); + return settingsResponse(cached); } const result = await fetchUserSettings(user.id); @@ -267,10 +305,11 @@ export async function GET(req: NextRequest) { timezone: result.timezone, webhook_url: result.webhook_url ?? null, discord_muted_until: result.discord_muted_until ?? null, + preferred_locale: result.preferred_locale, }; await cacheSet(cacheKey, response, SETTINGS_TTL); - return NextResponse.json(response); + return settingsResponse(response); } @@ -290,14 +329,14 @@ export async function PATCH(req: NextRequest) { ); } - let body: { is_public?: boolean; show_weekly_goals?: boolean; leaderboard_opt_in?: boolean; weekly_digest_opt_in?: boolean; pinned_repos?: string[]; wakatime_api_key?: string; discord_webhook_url?: string | null; timezone?: string; bio?: string; webhook_url?: string | null; discord_muted_until?: string | null }; + let body: { is_public?: boolean; show_weekly_goals?: boolean; leaderboard_opt_in?: boolean; weekly_digest_opt_in?: boolean; pinned_repos?: string[]; wakatime_api_key?: string; discord_webhook_url?: string | null; timezone?: string; bio?: string; webhook_url?: string | null; discord_muted_until?: string | null; preferred_locale?: string }; try { body = await req.json(); } catch (e) { return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); } - const { is_public, show_weekly_goals, leaderboard_opt_in, weekly_digest_opt_in, pinned_repos, wakatime_api_key, discord_webhook_url, timezone, bio, webhook_url, discord_muted_until } = body; + const { is_public, show_weekly_goals, leaderboard_opt_in, weekly_digest_opt_in, pinned_repos, wakatime_api_key, discord_webhook_url, timezone, bio, webhook_url, discord_muted_until, preferred_locale } = body; // Retrieve supported columns first const settingsResult = await fetchUserSettings(user.id); @@ -306,8 +345,8 @@ export async function PATCH(req: NextRequest) { return NextResponse.json({ error: "Failed to update settings" }, { status: 500 }); } - const { hasLeaderboardOptIn, hasPinnedRepos, hasWakatimeKey, hasWeeklyDigestOptIn, hasDiscordSettings, hasBio, hasWebhookUrl, hasDiscordMutedUntil } = settingsResult; - const updates: { is_public?: boolean; public_since?: string | null; show_weekly_goals?: boolean; leaderboard_opt_in?: boolean; weekly_digest_opt_in?: boolean; pinned_repos?: string[]; wakatime_api_key_encrypted?: string | null; wakatime_api_key_iv?: string | null; discord_webhook_url?: string | null; timezone?: string; bio?: string; webhook_url?: string | null; discord_muted_until?: string | null } = {}; + const { hasLeaderboardOptIn, hasPinnedRepos, hasWakatimeKey, hasWeeklyDigestOptIn, hasDiscordSettings, hasBio, hasWebhookUrl, hasDiscordMutedUntil, hasPreferredLocale } = settingsResult; + const updates: { is_public?: boolean; public_since?: string | null; show_weekly_goals?: boolean; leaderboard_opt_in?: boolean; weekly_digest_opt_in?: boolean; pinned_repos?: string[]; wakatime_api_key_encrypted?: string | null; wakatime_api_key_iv?: string | null; discord_webhook_url?: string | null; timezone?: string; bio?: string; webhook_url?: string | null; discord_muted_until?: string | null; preferred_locale?: string } = {}; if (is_public !== undefined && is_public !== null && typeof is_public === "boolean") { updates.is_public = is_public; @@ -422,9 +461,17 @@ export async function PATCH(req: NextRequest) { } } + if (hasPreferredLocale && preferred_locale !== undefined) { + if (!isValidLocale(preferred_locale)) { + return NextResponse.json({ error: "Unsupported locale" }, { status: 400 }); + } + + updates.preferred_locale = preferred_locale; + } + // If there are no updates (or none that are supported by the schema) if (Object.keys(updates).length === 0) { - return NextResponse.json({ + return settingsResponse({ id: (settingsResult.data as any).id, github_login: (settingsResult.data as any).github_login, bio: (settingsResult.data as any).bio ?? "", @@ -439,6 +486,7 @@ export async function PATCH(req: NextRequest) { timezone: settingsResult.timezone, webhook_url: settingsResult.webhook_url ?? null, discord_muted_until: settingsResult.discord_muted_until ?? null, + preferred_locale: settingsResult.preferred_locale, }); } @@ -455,6 +503,7 @@ export async function PATCH(req: NextRequest) { if (hasDiscordSettings) selectCols.push("discord_webhook_url", "timezone"); if (hasDiscordMutedUntil) selectCols.push("discord_muted_until"); if (hasWebhookUrl) selectCols.push("webhook_url"); + if (hasPreferredLocale) selectCols.push("preferred_locale"); const { data: updated, error: updateError } = await supabaseAdmin .from("users") @@ -487,7 +536,7 @@ export async function PATCH(req: NextRequest) { } } - return NextResponse.json({ + return settingsResponse({ id: (updated as any).id, github_login: (updated as any).github_login, bio: (updated as any).bio ?? "", @@ -502,5 +551,6 @@ export async function PATCH(req: NextRequest) { timezone: (updated as any).timezone || "UTC", webhook_url: (updated as any).webhook_url ?? null, discord_muted_until: (updated as any).discord_muted_until ?? null, + preferred_locale: (updated as any).preferred_locale || defaultLocale, }); } diff --git a/src/app/auth/signin/page.tsx b/src/app/auth/signin/page.tsx index 6a8921e70..233894fbc 100644 --- a/src/app/auth/signin/page.tsx +++ b/src/app/auth/signin/page.tsx @@ -4,6 +4,7 @@ import { signIn } from "next-auth/react"; import { Suspense, useEffect, useRef } from "react"; import { useSearchParams } from "next/navigation"; import Link from "next/link"; +import { useTranslations } from "next-intl"; const A = "#818cf8"; const ERR = "#f87171"; @@ -12,27 +13,22 @@ const DISP = "var(--font-syne, system-ui, sans-serif)"; /** Maps NextAuth error codes → user-facing messages. */ const AUTH_ERROR_MESSAGES: Record = { - github: - "GitHub sign-in failed. This is usually caused by incorrect OAuth credentials or a mismatched callback URL. Check your GitHub OAuth App settings and try again.", - OAuthCallback: - "The OAuth callback could not be completed. Please try signing in again.", - OAuthSignin: - "Could not start the GitHub sign-in flow. Please try again.", - Configuration: - "There is a server configuration error. Please contact the site administrator.", - AccessDenied: - "Access was denied. You may have cancelled the GitHub authorization.", - Verification: - "The sign-in link has expired or has already been used.", - Default: - "An unexpected authentication error occurred. Please try again.", + github: "githubError", + OAuthCallback: "oauthCallbackError", + OAuthSignin: "oauthSigninError", + Configuration: "configurationError", + AccessDenied: "accessDeniedError", + Verification: "verificationError", + Default: "defaultError", }; -function getErrorMessage(error: string): string { +function getErrorMessageKey(error: string): string { return AUTH_ERROR_MESSAGES[error] ?? AUTH_ERROR_MESSAGES.Default; } function AuthErrorBanner({ error }: { error: string }) { + const t = useTranslations("auth"); + return (
- ⚠ Sign-in failed + ⚠ {t("signInFailed")}

- {getErrorMessage(error)} + {t(getErrorMessageKey(error))}

); @@ -108,6 +104,8 @@ function MouseSpotlight() { * boundary because useSearchParams() opts the subtree out of static rendering. */ function SignInContent() { + const t = useTranslations("auth"); + const common = useTranslations("common"); const searchParams = useSearchParams(); const error = searchParams.get("error"); @@ -162,7 +160,7 @@ function SignInContent() { textDecoration: "none", fontSize:12 }} > - ← Back to home + ← {common("backToHome")} @@ -193,8 +191,8 @@ function SignInContent() { margin: "0 0 16px", }} > - WELCOME
- BACK. + {t("welcome")}
+ {t("back")}

- Track streaks, PR velocity & coding growth. + {t("tagline")}

{error && } @@ -214,10 +212,10 @@ function SignInContent() {
- MIT License · Self-hostable · Free forever + {t("licenseLine")}
diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 3c7a6a7d5..0cf0bed5f 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -10,10 +10,13 @@ import DashboardSSEProvider from "@/components/DashboardSSEProvider"; import StreakAtRiskBanner from "@/components/StreakAtRiskBanner"; import ThrottleBanner from "@/components/ThrottleBanner"; import CustomizableDashboard from "@/components/dashboard/CustomizableDashboard"; +import { getTranslations } from "next-intl/server"; export default async function DashboardPage() { const session = await getServerSession(authOptions); if (!session) redirect("/"); + const t = await getTranslations("dashboard"); + const common = await getTranslations("common"); return ( @@ -28,14 +31,14 @@ export default async function DashboardPage() { href="/wrapped" className="inline-flex w-full sm:w-auto justify-center items-center gap-2 rounded-xl border border-[var(--accent)] bg-[var(--accent)]/10 px-5 py-2.5 text-sm font-semibold text-[var(--accent)] shadow-sm shadow-[var(--accent)]/20 transition-all hover:bg-[var(--accent)]/20 hover:scale-[1.02]" > - Year in Code + {t("yearInCode")} - Settings + {common("settings")} @@ -58,21 +61,19 @@ export default async function DashboardPage() {
- New Feature + {t("newFeature")} - AI Resume Generator + {t("resumeGenerator")}

- Generate an ATS-Friendly CV Backed by Your Real Code + {t("resumeHeadline")}

- Analyze your GitHub contributions, merged PRs, and lines of code - changed to automatically generate professional bullet points for - your target roles. + {t("resumeDescription")}

@@ -80,7 +81,7 @@ export default async function DashboardPage() { href="/dashboard/career-intelligence" className="inline-flex items-center gap-1.5 rounded-lg bg-gradient-to-r from-violet-600 to-indigo-600 px-5 py-2.5 text-xs font-bold text-white shadow-md shadow-indigo-500/20 hover:scale-[1.03] transition-all whitespace-nowrap" > - Build Resume + {t("buildResume")} @@ -90,4 +91,4 @@ export default async function DashboardPage() {
); -} \ No newline at end of file +} diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx index 40611b095..f1b24533e 100644 --- a/src/app/dashboard/settings/page.tsx +++ b/src/app/dashboard/settings/page.tsx @@ -13,6 +13,8 @@ import { toast } from "sonner"; import Link from "next/link"; import { useRouter } from "next/navigation"; import WebhookManager from "@/components/webhook/WebhookManager"; +import { useLocale, useTranslations } from "next-intl"; +import { localeMetadata, locales, type AppLocale } from "@/i18n/config"; // ── Max length for the profile bio ────────────────────────────────────────── const BIO_MAX = 160; @@ -31,6 +33,7 @@ interface UserSettings { timezone?: string; pinned_repos?: string[]; discord_muted_until?: string | null; + preferred_locale?: AppLocale; } interface LinkedAccount { @@ -159,6 +162,9 @@ function SettingsPageFallback() { } function SettingsPageContent() { + const t = useTranslations("settings"); + const common = useTranslations("common"); + const activeLocale = useLocale() as AppLocale; const { data: session, status } = useSession(); const searchParams = useSearchParams(); const router = useRouter(); @@ -183,6 +189,8 @@ function SettingsPageContent() { const [discordWebhook, setDiscordWebhook] = useState(""); const [timezone, setTimezone] = useState(""); const [savingDiscord, setSavingDiscord] = useState(false); + const [preferredLocale, setPreferredLocale] = useState(activeLocale); + const [savingLanguage, setSavingLanguage] = useState(false); const [testingDiscord, setTestingDiscord] = useState(false); const [discordMutedUntil, setDiscordMutedUntil] = useState(null); const [muteDuration, setMuteDuration] = useState(1); @@ -294,6 +302,7 @@ function SettingsPageContent() { setTimezone(data.timezone || "UTC"); setDiscordMutedUntil(data.discord_muted_until ?? null); setWebhookUrl(data.webhook_url ?? null); + setPreferredLocale(data.preferred_locale || activeLocale); } } catch (error) { console.error("Failed to load settings:", error); @@ -303,7 +312,37 @@ function SettingsPageContent() { } loadSettings(); - }, [session, status]); + }, [activeLocale, session, status]); + + const handleSaveLanguage = async (value: AppLocale) => { + if (!settings) return; + + setPreferredLocale(value); + setSavingLanguage(true); + + try { + const res = await fetch("/api/user/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ preferred_locale: value }), + }); + + if (res.ok) { + const updated = await res.json(); + setSettings(updated); + setPreferredLocale(updated.preferred_locale || value); + toast.success(t("languageSaved")); + router.refresh(); + } else { + const err = await res.json(); + toast.error(err.error || t("languageSaveFailed")); + } + } catch { + toast.error(t("languageSaveFailed")); + } finally { + setSavingLanguage(false); + } + }; // Load active repos for spotlight pinning useEffect(() => { @@ -1036,16 +1075,55 @@ function SettingsPageContent() {

- Application Theme + {t("appearanceTitle")}

- Choose a theme for the DevTrack interface. + {t("appearanceDescription")}

+
+
+
+

+ {t("languageTitle")} +

+

+ {t("languageDescription")} +

+
+
+ + + {savingLanguage && ( +

+ {common("saving")} +

+ )} +
+
+
+
@@ -1226,10 +1304,10 @@ function SettingsPageContent() {

- Weekly Email Digest + {t("weeklyDigestTitle")}

- Receive an optional weekly email digest every Monday morning summarizing your coding habits. + {t("weeklyDigestDescription")}

@@ -1262,17 +1340,17 @@ function SettingsPageContent() {

- Notifications + {t("notificationsTitle")}

- Send a weekly summary of your activity to Slack or Discord via webhook. + {t("notificationsDescription")}

- {webhookSaving ? "Saving..." : "Save"} + {webhookSaving ? common("saving") : common("save")}
@@ -1330,11 +1408,10 @@ function SettingsPageContent() {

- Connected Accounts + {t("connectedAccountsTitle")}

- Link additional GitHub accounts and switch between them on the - dashboard. + {t("connectedAccountsDescription")}

@@ -1343,7 +1420,7 @@ function SettingsPageContent() { prefetch={false} className="inline-flex items-center justify-center rounded-lg bg-[var(--accent)] px-4 py-2 text-sm font-medium text-[var(--accent-foreground)] hover:opacity-90 transition-opacity" > - Add GitHub Account + {t("addGitHubAccount")}
@@ -1366,11 +1443,11 @@ function SettingsPageContent() { - Loading linked accounts... + {t("loadingLinkedAccounts")}
) : linkedAccounts.length === 0 ? (
- No linked GitHub accounts yet. + {t("noLinkedAccounts")}
) : (
@@ -1410,10 +1487,10 @@ function SettingsPageContent() {

- Wakatime Integration + {t("wakatimeTitle")}

- Connect your Wakatime account to display accurate coding time and language usage. + {t("wakatimeDescription")}

@@ -1421,7 +1498,7 @@ function SettingsPageContent() {
- {savingWakatime ? "Saving..." : "Save"} + {savingWakatime ? common("saving") : common("save")}

@@ -1456,10 +1533,10 @@ function SettingsPageContent() {

- Discord Integration + {t("discordTitle")}

- Receive streak reminders and milestone alerts in your Discord server. + {t("discordDescription")}

@@ -1467,7 +1544,7 @@ function SettingsPageContent() {