Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type { ServeOptions, Server } from 'bun'
import path from 'node:path'

const SERVER_PORT = Number(process.env.PORT ?? 3000)
Expand Down Expand Up @@ -254,7 +253,7 @@ async function initializeServer() {
log.error(`Uncaught server error: ${error.message}`)
return new Response('Internal Server Error', { status: 500 })
},
} as unknown as ServeOptions) as Server
})

log.success(`Server listening on http://localhost:${String(server.port)}`)
}
Expand Down
7 changes: 4 additions & 3 deletions src/client/i18n.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import i18n from "i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import { initReactI18next } from "react-i18next";
import { defaultLanguageCode, supportedLanguageCodes } from "./languages";
import { en } from "./locales/en";
import { fr } from "./locales/fr";

Expand All @@ -12,9 +13,9 @@ i18n
en,
fr,
},
lng: typeof window === "undefined" ? "en" : undefined, // Force English on server
fallbackLng: "en",
supportedLngs: ["en", "fr"],
lng: typeof window === "undefined" ? defaultLanguageCode : undefined, // Force English on server
fallbackLng: defaultLanguageCode,
supportedLngs: supportedLanguageCodes,
load: "languageOnly",
interpolation: {
escapeValue: false, // react already safes from xss
Expand Down
28 changes: 28 additions & 0 deletions src/client/languages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
export const languageOptions = [
{ code: "en", labelKey: "languages.en" },
{ code: "fr", labelKey: "languages.fr" },
] as const;

export type LanguageCode = (typeof languageOptions)[number]["code"];

export const languageCodeTuple = languageOptions.map(
(option) => option.code,
) as [LanguageCode, ...LanguageCode[]];

export const supportedLanguageCodes = languageOptions.map(
(option) => option.code,
);

export const defaultLanguageCode: LanguageCode = "en";

export const normalizeLanguageCode = (
language: string | null | undefined,
): LanguageCode => {
const normalizedLanguage = (language ?? defaultLanguageCode).split("-")[0];

if (supportedLanguageCodes.includes(normalizedLanguage as LanguageCode)) {
return normalizedLanguage as LanguageCode;
}

return defaultLanguageCode;
};
38 changes: 38 additions & 0 deletions src/client/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const en = {
saveChanges: "Save changes",
savedSuccess: "Settings saved successfully",
savedError: "Failed to save settings",
genericError: "Something went wrong",
},
search: {
placeholder: "What are you looking for?",
Expand Down Expand Up @@ -113,5 +114,42 @@ export const en = {
en: "English",
fr: "Français",
},
setup: {
title: "Initial Configuration",
steps: {
language: "Language",
languageDescription: "Choose your preferred language",
safeSearch: "Content Filtering",
safeSearchDescription: "Configure content filtering",
admin: "Admin Account",
adminDescription: "Create your administrator account",
},
language: {
title: "Language",
description: "Please select your preferred language for the interface.",
continue: "Continue",
},
admin: {
name: "Name",
email: "Email",
password: "Password",
confirmPassword: "Confirm Password",
createAccount: "Create Account",
nameRequired: "Name is required",
emailInvalid: "Invalid email",
passwordMinLength: "Password must be at least 8 characters",
passwordsDoNotMatch: "Passwords do not match",
},
safeSearch: {
title: "Safe Search",
description: "Select the level of content filtering you prefer.",
},
},
validation: {
required: "This field is required",
invalidEmail: "Invalid email",
minLength: "Must be at least {{count}} characters",
invalid: "Invalid value",
},
},
};
41 changes: 41 additions & 0 deletions src/client/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const fr = {
saveChanges: "Enregistrer les modifications",
savedSuccess: "Paramètres enregistrés avec succès",
savedError: "Échec de l'enregistrement des paramètres",
genericError: "Une erreur est survenue",
},
search: {
placeholder: "Que recherchez-vous ?",
Expand Down Expand Up @@ -114,5 +115,45 @@ export const fr = {
en: "English",
fr: "Français",
},
setup: {
title: "Configuration initiale",
steps: {
language: "Langue",
languageDescription: "Choisissez votre langue préférée",
safeSearch: "Filtrage",
safeSearchDescription: "Configurez le filtrage de contenu",
admin: "Compte Admin",
adminDescription: "Créez votre compte administrateur",
},
language: {
title: "Langue",
description:
"Veuillez sélectionner votre langue préférée pour l'interface.",
continue: "Continuer",
},
admin: {
name: "Nom",
email: "Email",
password: "Mot de passe",
confirmPassword: "Confirmer le mot de passe",
createAccount: "Créer le compte",
nameRequired: "Le nom est requis",
emailInvalid: "Email invalide",
passwordMinLength:
"Le mot de passe doit contenir au moins 8 caractères",
passwordsDoNotMatch: "Les mots de passe ne correspondent pas",
},
safeSearch: {
title: "Safe Search",
description:
"Sélectionnez le niveau de filtrage de contenu que vous préférez.",
},
},
validation: {
required: "Ce champ est requis",
invalidEmail: "Email invalide",
minLength: "Doit contenir au moins {{count}} caractères",
invalid: "Valeur invalide",
},
},
};
15 changes: 15 additions & 0 deletions src/client/zod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { z } from "zod";
import i18n from "./i18n";
import { normalizeLanguageCode } from "./languages";

const applyZodLocale = () => {
const language = normalizeLanguageCode(
i18n.resolvedLanguage || i18n.language,
);
const locale = language === "fr" ? z.locales.fr() : z.locales.en();
z.config(locale);
};

applyZodLocale();
i18n.on("initialized", applyZodLocale);
i18n.on("languageChanged", applyZodLocale);
1 change: 1 addition & 0 deletions src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { getPublicConfig } from "@/server/infrastructure/functions/instance-conf
import TanStackQueryDevtools from "../integrations/tanstack-query/devtools";
import appCss from "../styles.css?url";
import "@/client/i18n";
import "@/client/zod";
import { useTranslation } from "react-i18next";
import { LanguageSync } from "@/client/components/language-sync";

Expand Down
3 changes: 2 additions & 1 deletion src/routes/_authed/-components/settings/general-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
CardHeader,
CardTitle,
} from "@/client/components/ui/card";
import type { LanguageCode } from "@/client/languages";
import { LanguageSwitcher } from "@/routes/_authed/-components/settings/language-switcher";
import { useSettingsLayout } from "@/routes/_authed/settings";

Expand All @@ -30,7 +31,7 @@ export function GeneralSection() {
<LanguageSwitcher
value={userSettings.language}
onValueChange={(val) =>
handleUserSettingChange("language", val as "en" | "fr")
handleUserSettingChange("language", val as LanguageCode)
}
/>
</CardContent>
Expand Down
20 changes: 11 additions & 9 deletions src/routes/_authed/-components/settings/language-switcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/client/components/ui/select";
import { languageOptions, normalizeLanguageCode } from "@/client/languages";
import { authClient } from "@/server/infrastructure/auth/client";
import {
getUserSettings,
Expand All @@ -26,9 +27,10 @@ export function LanguageSwitcher({
const { data: session } = authClient.useSession();

const changeLanguage = async (lng: string) => {
const normalizedLng = normalizeLanguageCode(lng);
// Controlled mode: just bubble up the change
if (onValueChange) {
onValueChange(lng);
onValueChange(normalizedLng);
return;
}

Expand All @@ -37,30 +39,27 @@ export function LanguageSwitcher({
try {
// Fetch current settings first to merge
const currentSettings = await getUserSettings();
const normalizedLng = lng.split("-")[0] as "en" | "fr";

// Save to server first
await saveUserSettings({
data: { ...currentSettings, language: normalizedLng },
});

// Then update client state
i18n.changeLanguage(lng);
i18n.changeLanguage(normalizedLng);
} catch (error) {
console.error("Failed to save language preference:", error);
}
} else {
// Guest user - just change language
i18n.changeLanguage(lng);
i18n.changeLanguage(normalizedLng);
}
};

// Ensure we have a valid language selection, fallback to 'en'
// Use resolvedLanguage if available, otherwise language, otherwise 'en'
const currentLanguage =
value ||
(i18n.resolvedLanguage || i18n.language || "en").split("-")[0] ||
"en";
value || normalizeLanguageCode(i18n.resolvedLanguage || i18n.language);

return (
<div className="flex items-center gap-2">
Expand All @@ -70,8 +69,11 @@ export function LanguageSwitcher({
<SelectValue placeholder={t("settings.language")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="en">{t("languages.en")}</SelectItem>
<SelectItem value="fr">{t("languages.fr")}</SelectItem>
{languageOptions.map((option) => (
<SelectItem key={option.code} value={option.code}>
{t(option.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
Expand Down
9 changes: 4 additions & 5 deletions src/routes/_authed/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Button } from "@/client/components/ui/button";
import { normalizeLanguageCode } from "@/client/languages";
import { sessionQueryOptions } from "@/routes/_authed";
import {
getInstanceConfig,
Expand Down Expand Up @@ -150,11 +151,9 @@ function SettingsLayout() {
});

// Apply language change if needed
const currentLng = (
i18n.resolvedLanguage ||
i18n.language ||
"en"
).split("-")[0];
const currentLng = normalizeLanguageCode(
i18n.resolvedLanguage || i18n.language,
);
if (userSettings.language !== currentLng) {
i18n.changeLanguage(userSettings.language);
}
Expand Down
Loading