kaneo (minimalist kanban) fork to experiment adding a tangled integration github.com/usekaneo/kaneo
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(web): add i18next, locale hook, and Vite i18n integration

Tin 86033857 c2204ab2

+279 -8
+1 -1
apps/web/index.html
··· 1 1 <!doctype html> 2 - <html lang="en"> 2 + <html lang="en-US"> 3 3 4 4 <head> 5 5 <meta charset="UTF-8" />
+2
apps/web/package.json
··· 73 73 "hono": "^4.12.4", 74 74 "immer": "^11.1.4", 75 75 "input-otp": "^1.4.2", 76 + "i18next": "^25.5.3", 76 77 "lowlight": "^3.3.0", 77 78 "lucide-react": "^0.577.0", 78 79 "marked": "^17.0.4", ··· 81 82 "react-day-picker": "9.14.0", 82 83 "react-dom": "^19.2.4", 83 84 "react-hook-form": "^7.71.2", 85 + "react-i18next": "^15.7.3", 84 86 "react-markdown": "^10.1.0", 85 87 "react-use-websocket": "^4.11.1", 86 88 "shiki": "^4.0.2",
+27
apps/web/src/hooks/use-locale.ts
··· 1 + import { useMemo } from "react"; 2 + import { useTranslation } from "react-i18next"; 3 + import { authClient } from "@/lib/auth-client"; 4 + import { getBrowserLocale, resolveLocale } from "@/lib/i18n"; 5 + 6 + export function useLocale() { 7 + const { i18n } = useTranslation(); 8 + 9 + const locale = useMemo( 10 + () => resolveLocale(i18n.resolvedLanguage, getBrowserLocale()), 11 + [i18n.resolvedLanguage], 12 + ); 13 + 14 + const setLocale = async (nextLocale: string) => { 15 + const { error } = await authClient.updateUser({ locale: nextLocale }); 16 + if (error) { 17 + throw new Error(error.message || "Failed to update locale"); 18 + } 19 + 20 + await i18n.changeLanguage(resolveLocale(nextLocale, null)); 21 + }; 22 + 23 + return { 24 + locale, 25 + setLocale, 26 + }; 27 + }
+77
apps/web/src/lib/format.ts
··· 1 + import { i18n } from "./i18n"; 2 + 3 + type DateInput = Date | string | number; 4 + 5 + function toDate(input: DateInput) { 6 + return input instanceof Date ? input : new Date(input); 7 + } 8 + 9 + function getLocale(locale?: string) { 10 + return locale || i18n.resolvedLanguage || i18n.language || "en-US"; 11 + } 12 + 13 + export function formatDate( 14 + value: DateInput, 15 + options?: Intl.DateTimeFormatOptions, 16 + locale?: string, 17 + ) { 18 + return new Intl.DateTimeFormat(getLocale(locale), options).format( 19 + toDate(value), 20 + ); 21 + } 22 + 23 + export function formatDateShort(value: DateInput, locale?: string) { 24 + return formatDate( 25 + value, 26 + { 27 + month: "short", 28 + day: "numeric", 29 + }, 30 + locale, 31 + ); 32 + } 33 + 34 + export function formatDateMedium(value: DateInput, locale?: string) { 35 + return formatDate( 36 + value, 37 + { 38 + month: "short", 39 + day: "numeric", 40 + year: "numeric", 41 + }, 42 + locale, 43 + ); 44 + } 45 + 46 + export function formatRelativeTime( 47 + value: DateInput, 48 + locale?: string, 49 + now = new Date(), 50 + ) { 51 + const target = toDate(value); 52 + const diffMs = target.getTime() - now.getTime(); 53 + const diffSeconds = Math.round(diffMs / 1000); 54 + const absSeconds = Math.abs(diffSeconds); 55 + 56 + const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [ 57 + ["year", 60 * 60 * 24 * 365], 58 + ["month", 60 * 60 * 24 * 30], 59 + ["week", 60 * 60 * 24 * 7], 60 + ["day", 60 * 60 * 24], 61 + ["hour", 60 * 60], 62 + ["minute", 60], 63 + ["second", 1], 64 + ]; 65 + 66 + const formatter = new Intl.RelativeTimeFormat(getLocale(locale), { 67 + numeric: "auto", 68 + }); 69 + 70 + for (const [unit, unitSeconds] of units) { 71 + if (absSeconds >= unitSeconds || unit === "second") { 72 + return formatter.format(Math.round(diffSeconds / unitSeconds), unit); 73 + } 74 + } 75 + 76 + return formatter.format(0, "second"); 77 + }
+22
apps/web/src/lib/i18n/domain.ts
··· 1 + import i18n from "i18next"; 2 + 3 + export function getStatusLabel(status: string) { 4 + return i18n.t(`tasks:status.${status}`, { 5 + defaultValue: toDisplayCase(status), 6 + }); 7 + } 8 + 9 + export function getPriorityLabel(priority: string) { 10 + return i18n.t(`tasks:priority.${priority}`, { 11 + defaultValue: toDisplayCase(priority), 12 + }); 13 + } 14 + 15 + function toDisplayCase(value: string) { 16 + return value 17 + .replace(/[-_]/g, " ") 18 + .split(" ") 19 + .filter(Boolean) 20 + .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) 21 + .join(" "); 22 + }
+51
apps/web/src/lib/i18n/index.ts
··· 1 + import { 2 + type AppLocale, 3 + defaultLocale, 4 + resources, 5 + supportedLocales, 6 + } from "@i18n/resources"; 7 + import i18n from "i18next"; 8 + import { initReactI18next } from "react-i18next"; 9 + 10 + function getLanguageCode(locale: string) { 11 + return locale.toLowerCase().split("-")[0]; 12 + } 13 + 14 + export function resolveLocale( 15 + preferredLocale?: string | null, 16 + browserLocale?: string | null, 17 + ): AppLocale { 18 + const candidates = [preferredLocale, browserLocale] 19 + .filter(Boolean) 20 + .map((value) => value?.toLowerCase()); 21 + 22 + for (const candidate of candidates) { 23 + if (!candidate) continue; 24 + const exactMatch = supportedLocales.find((locale) => locale === candidate); 25 + if (exactMatch) return exactMatch; 26 + 27 + const languageMatch = supportedLocales.find( 28 + (locale) => getLanguageCode(locale) === getLanguageCode(candidate), 29 + ); 30 + if (languageMatch) return languageMatch; 31 + } 32 + 33 + return defaultLocale; 34 + } 35 + 36 + export function getBrowserLocale(): string | null { 37 + if (typeof navigator === "undefined") return null; 38 + return navigator.language || navigator.languages?.[0] || null; 39 + } 40 + 41 + void i18n.use(initReactI18next).init({ 42 + resources, 43 + lng: resolveLocale(null, getBrowserLocale()), 44 + fallbackLng: defaultLocale, 45 + defaultNS: "common", 46 + interpolation: { 47 + escapeValue: false, 48 + }, 49 + }); 50 + 51 + export { i18n };
+20
apps/web/src/lib/i18n/provider.tsx
··· 1 + import { type PropsWithChildren, useEffect, useMemo } from "react"; 2 + import { I18nextProvider } from "react-i18next"; 3 + import useAuth from "@/components/providers/auth-provider/hooks/use-auth"; 4 + import { getBrowserLocale, i18n, resolveLocale } from "./index"; 5 + 6 + export function AppI18nProvider({ children }: PropsWithChildren) { 7 + const { user } = useAuth(); 8 + 9 + const resolvedLocale = useMemo( 10 + () => resolveLocale(user?.locale, getBrowserLocale()), 11 + [user?.locale], 12 + ); 13 + 14 + useEffect(() => { 15 + void i18n.changeLanguage(resolvedLocale); 16 + document.documentElement.lang = resolvedLocale; 17 + }, [resolvedLocale]); 18 + 19 + return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>; 20 + }
+7 -4
apps/web/src/main.tsx
··· 9 9 import AuthProvider from "./components/providers/auth-provider"; 10 10 import { ThemeProvider } from "./components/providers/theme-provider"; 11 11 import { KeyboardShortcutsProvider } from "./hooks/use-keyboard-shortcuts"; 12 + import { AppI18nProvider } from "./lib/i18n/provider"; 12 13 import { routeTree } from "./routeTree.gen"; 13 14 14 15 console.log(` ··· 56 57 <QueryClientProvider client={queryClient}> 57 58 <ThemeProvider> 58 59 <AuthProvider> 59 - <KeyboardShortcutsProvider> 60 - <App /> 61 - <KeyboardShortcutsHelp /> 62 - </KeyboardShortcutsProvider> 60 + <AppI18nProvider> 61 + <KeyboardShortcutsProvider> 62 + <App /> 63 + <KeyboardShortcutsHelp /> 64 + </KeyboardShortcutsProvider> 65 + </AppI18nProvider> 63 66 </AuthProvider> 64 67 </ThemeProvider> 65 68 </QueryClientProvider>
+4 -2
apps/web/tsconfig.app.json
··· 9 9 10 10 /* Bundler mode */ 11 11 "moduleResolution": "bundler", 12 + "resolveJsonModule": true, 12 13 "allowImportingTsExtensions": true, 13 14 "isolatedModules": true, 14 15 "moduleDetection": "force", ··· 22 23 "noFallthroughCasesInSwitch": true, 23 24 "noUncheckedSideEffectImports": true, 24 25 "paths": { 25 - "@/*": ["./src/*"] 26 + "@/*": ["./src/*"], 27 + "@i18n/*": ["../../i18n/*"] 26 28 } 27 29 }, 28 - "include": ["src"], 30 + "include": ["src", "../../i18n"], 29 31 "exclude": ["../api/**/*", "../docs/**/*", "../../packages/**/*"], 30 32 "baseUrl": "." 31 33 }
+2 -1
apps/web/tsconfig.json
··· 7 7 "compilerOptions": { 8 8 "baseUrl": ".", 9 9 "paths": { 10 - "@/*": ["./src/*"] 10 + "@/*": ["./src/*"], 11 + "@i18n/*": ["../../i18n/*"] 11 12 } 12 13 }, 13 14 "exclude": ["../api/**/*", "../docs/**/*", "../../packages/**/*"]
+1
apps/web/vite.config.ts
··· 33 33 resolve: { 34 34 alias: { 35 35 "@": path.resolve(__dirname, "./src"), 36 + "@i18n": path.resolve(__dirname, "../../i18n"), 36 37 }, 37 38 }, 38 39 build: {
+65
pnpm-lock.yaml
··· 395 395 hono: 396 396 specifier: 4.12.7 397 397 version: 4.12.7 398 + i18next: 399 + specifier: ^25.5.3 400 + version: 25.10.10(typescript@5.8.3) 398 401 immer: 399 402 specifier: ^11.1.4 400 403 version: 11.1.4 ··· 425 428 react-hook-form: 426 429 specifier: ^7.71.2 427 430 version: 7.71.2(react@19.2.4) 431 + react-i18next: 432 + specifier: ^15.7.3 433 + version: 15.7.4(i18next@25.10.10(typescript@5.8.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3) 428 434 react-markdown: 429 435 specifier: ^10.1.0 430 436 version: 10.1.0(@types/react@19.2.14)(react@19.2.4) ··· 905 911 906 912 '@babel/runtime@7.28.6': 907 913 resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} 914 + engines: {node: '>=6.9.0'} 915 + 916 + '@babel/runtime@7.29.2': 917 + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} 908 918 engines: {node: '>=6.9.0'} 909 919 910 920 '@babel/template@7.28.6': ··· 5225 5235 resolution: {integrity: sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==} 5226 5236 engines: {node: '>=16.9.0'} 5227 5237 5238 + html-parse-stringify@3.0.1: 5239 + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} 5240 + 5228 5241 html-to-text@9.0.5: 5229 5242 resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==} 5230 5243 engines: {node: '>=14'} ··· 5261 5274 resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} 5262 5275 engines: {node: '>=18'} 5263 5276 hasBin: true 5277 + 5278 + i18next@25.10.10: 5279 + resolution: {integrity: sha512-cqUW2Z3EkRx7NqSyywjkgCLK7KLCL6IFVFcONG7nVYIJ3ekZ1/N5jUsihHV6Bq37NfhgtczxJcxduELtjTwkuQ==} 5280 + peerDependencies: 5281 + typescript: ^5 || ^6 5282 + peerDependenciesMeta: 5283 + typescript: 5284 + optional: true 5264 5285 5265 5286 iconv-lite@0.7.2: 5266 5287 resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} ··· 6449 6470 peerDependencies: 6450 6471 react: ^16.8.0 || ^17 || ^18 || ^19 6451 6472 6473 + react-i18next@15.7.4: 6474 + resolution: {integrity: sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==} 6475 + peerDependencies: 6476 + i18next: '>= 23.4.0' 6477 + react: '>= 16.8.0' 6478 + react-dom: '*' 6479 + react-native: '*' 6480 + typescript: ^5 6481 + peerDependenciesMeta: 6482 + react-dom: 6483 + optional: true 6484 + react-native: 6485 + optional: true 6486 + typescript: 6487 + optional: true 6488 + 6452 6489 react-markdown@10.1.0: 6453 6490 resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} 6454 6491 peerDependencies: ··· 7153 7190 optional: true 7154 7191 yaml: 7155 7192 optional: true 7193 + 7194 + void-elements@3.1.0: 7195 + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} 7196 + engines: {node: '>=0.10.0'} 7156 7197 7157 7198 w3c-keyname@2.2.8: 7158 7199 resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} ··· 7999 8040 - supports-color 8000 8041 8001 8042 '@babel/runtime@7.28.6': {} 8043 + 8044 + '@babel/runtime@7.29.2': {} 8002 8045 8003 8046 '@babel/template@7.28.6': 8004 8047 dependencies: ··· 13163 13206 13164 13207 hono@4.12.7: {} 13165 13208 13209 + html-parse-stringify@3.0.1: 13210 + dependencies: 13211 + void-elements: 3.1.0 13212 + 13166 13213 html-to-text@9.0.5: 13167 13214 dependencies: 13168 13215 '@selderee/plugin-htmlparser2': 0.11.0 ··· 13205 13252 human-signals@8.0.1: {} 13206 13253 13207 13254 husky@9.1.7: {} 13255 + 13256 + i18next@25.10.10(typescript@5.8.3): 13257 + dependencies: 13258 + '@babel/runtime': 7.29.2 13259 + optionalDependencies: 13260 + typescript: 5.8.3 13208 13261 13209 13262 iconv-lite@0.7.2: 13210 13263 dependencies: ··· 14606 14659 dependencies: 14607 14660 react: 19.2.4 14608 14661 14662 + react-i18next@15.7.4(i18next@25.10.10(typescript@5.8.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3): 14663 + dependencies: 14664 + '@babel/runtime': 7.28.6 14665 + html-parse-stringify: 3.0.1 14666 + i18next: 25.10.10(typescript@5.8.3) 14667 + react: 19.2.4 14668 + optionalDependencies: 14669 + react-dom: 19.2.4(react@19.2.4) 14670 + typescript: 5.8.3 14671 + 14609 14672 react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.4): 14610 14673 dependencies: 14611 14674 '@types/hast': 3.0.4 ··· 15458 15521 lightningcss: 1.31.1 15459 15522 tsx: 4.21.0 15460 15523 yaml: 2.8.2 15524 + 15525 + void-elements@3.1.0: {} 15461 15526 15462 15527 w3c-keyname@2.2.8: {} 15463 15528