diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index 2ccf1c168..2751dd8ca 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -16,6 +16,7 @@ "@alparr/api": "workspace:^0.1.0", "@alparr/auth": "workspace:^0.1.0", "@alparr/db": "workspace:^0.1.0", + "@alparr/translation": "workspace:^", "@alparr/ui": "workspace:^0.1.0", "@alparr/validation": "workspace:^0.1.0", "@mantine/core": "^7.3.1", diff --git a/apps/nextjs/src/app/[locale]/_client-providers/next-international.tsx b/apps/nextjs/src/app/[locale]/_client-providers/next-international.tsx new file mode 100644 index 000000000..27bfdea5e --- /dev/null +++ b/apps/nextjs/src/app/[locale]/_client-providers/next-international.tsx @@ -0,0 +1,15 @@ +import type { PropsWithChildren } from "react"; + +import { defaultLocale } from "@alparr/translation"; +import { I18nProviderClient } from "@alparr/translation/client"; + +export const NextInternationalProvider = ({ + children, + locale, +}: PropsWithChildren<{ locale: string }>) => { + return ( + + {children} + + ); +}; diff --git a/apps/nextjs/src/app/providers.tsx b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx similarity index 100% rename from apps/nextjs/src/app/providers.tsx rename to apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx diff --git a/apps/nextjs/src/app/auth/login/_components/login-form.tsx b/apps/nextjs/src/app/[locale]/auth/login/_components/login-form.tsx similarity index 81% rename from apps/nextjs/src/app/auth/login/_components/login-form.tsx rename to apps/nextjs/src/app/[locale]/auth/login/_components/login-form.tsx index 15f8f904d..a556848cc 100644 --- a/apps/nextjs/src/app/auth/login/_components/login-form.tsx +++ b/apps/nextjs/src/app/[locale]/auth/login/_components/login-form.tsx @@ -15,9 +15,11 @@ import { IconAlertTriangle } from "@tabler/icons-react"; import type { z } from "zod"; import { signIn } from "@alparr/auth/client"; +import { useScopedI18n } from "@alparr/translation/client"; import { signInSchema } from "@alparr/validation"; export const LoginForm = () => { + const t = useScopedI18n("user"); const router = useRouter(); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(); @@ -54,10 +56,16 @@ export const LoginForm = () => {
void handleSubmit(v))}> - - + +
diff --git a/apps/nextjs/src/app/auth/login/page.tsx b/apps/nextjs/src/app/[locale]/auth/login/page.tsx similarity index 73% rename from apps/nextjs/src/app/auth/login/page.tsx rename to apps/nextjs/src/app/[locale]/auth/login/page.tsx index 56bcbb96e..ec70bac1a 100644 --- a/apps/nextjs/src/app/auth/login/page.tsx +++ b/apps/nextjs/src/app/[locale]/auth/login/page.tsx @@ -1,19 +1,23 @@ import { Card, Center, Stack, Text, Title } from "@mantine/core"; +import { getScopedI18n } from "@alparr/translation/server"; + import { LogoWithTitle } from "~/components/layout/logo"; import { LoginForm } from "./_components/login-form"; -export default function Login() { +export default async function Login() { + const t = await getScopedI18n("user.page.login"); + return (
- Log in to your account + {t("title")} - Welcome back! Please enter your credentials + {t("subtitle")} diff --git a/apps/nextjs/src/app/init/user/_components/init-user-form.tsx b/apps/nextjs/src/app/[locale]/init/user/_components/init-user-form.tsx similarity index 75% rename from apps/nextjs/src/app/init/user/_components/init-user-form.tsx rename to apps/nextjs/src/app/[locale]/init/user/_components/init-user-form.tsx index 72f697013..9afd37a30 100644 --- a/apps/nextjs/src/app/init/user/_components/init-user-form.tsx +++ b/apps/nextjs/src/app/[locale]/init/user/_components/init-user-form.tsx @@ -5,6 +5,7 @@ import { Button, PasswordInput, Stack, TextInput } from "@mantine/core"; import { useForm, zodResolver } from "@mantine/form"; import type { z } from "zod"; +import { useScopedI18n } from "@alparr/translation/client"; import { initUserSchema } from "@alparr/validation"; import { showErrorNotification, showSuccessNotification } from "~/notification"; @@ -12,6 +13,7 @@ import { api } from "~/utils/api"; export const InitUserForm = () => { const router = useRouter(); + const t = useScopedI18n("user"); const { mutateAsync, error, isPending } = api.user.initUser.useMutation(); const form = useForm({ validate: zodResolver(initUserSchema), @@ -20,7 +22,7 @@ export const InitUserForm = () => { initialValues: { username: "", password: "", - repeatPassword: "", + confirmPassword: "", }, }); @@ -52,14 +54,20 @@ export const InitUserForm = () => { )} > - - + + diff --git a/apps/nextjs/src/app/init/user/page.tsx b/apps/nextjs/src/app/[locale]/init/user/page.tsx similarity index 82% rename from apps/nextjs/src/app/init/user/page.tsx rename to apps/nextjs/src/app/[locale]/init/user/page.tsx index 8d8dccdcd..594c8c2b8 100644 --- a/apps/nextjs/src/app/init/user/page.tsx +++ b/apps/nextjs/src/app/[locale]/init/user/page.tsx @@ -2,6 +2,7 @@ import { notFound } from "next/navigation"; import { Card, Center, Stack, Text, Title } from "@mantine/core"; import { db } from "@alparr/db"; +import { getScopedI18n } from "@alparr/translation/server"; import { LogoWithTitle } from "~/components/layout/logo"; import { InitUserForm } from "./_components/init-user-form"; @@ -14,19 +15,21 @@ export default async function InitUser() { }); if (firstUser) { - return notFound(); + notFound(); } + const t = await getScopedI18n("user.page.init"); + return (
- New Alparr installation + {t("title")} - Please create the initial administator user. + {t("subtitle")} diff --git a/apps/nextjs/src/app/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx similarity index 68% rename from apps/nextjs/src/app/layout.tsx rename to apps/nextjs/src/app/[locale]/layout.tsx index f116aba0b..39182a7f5 100644 --- a/apps/nextjs/src/app/layout.tsx +++ b/apps/nextjs/src/app/[locale]/layout.tsx @@ -11,7 +11,8 @@ import { Notifications } from "@mantine/notifications"; import { uiConfiguration } from "@alparr/ui"; -import { TRPCReactProvider } from "./providers"; +import { NextInternationalProvider } from "./_client-providers/next-international"; +import { TRPCReactProvider } from "./_client-providers/trpc"; const fontSans = Inter({ subsets: ["latin"], @@ -30,7 +31,10 @@ export const metadata: Metadata = { description: "Simple monorepo with shared backend for web & mobile apps", }; -export default function Layout(props: { children: React.ReactNode }) { +export default function Layout(props: { + children: React.ReactNode; + params: { locale: string }; +}) { const colorScheme = "dark"; return ( @@ -40,13 +44,15 @@ export default function Layout(props: { children: React.ReactNode }) { - - - {props.children} - + + + + {props.children} + + diff --git a/apps/nextjs/src/app/[locale]/loading.tsx b/apps/nextjs/src/app/[locale]/loading.tsx new file mode 100644 index 000000000..201f429d0 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/loading.tsx @@ -0,0 +1,9 @@ +import { Center, Loader } from "@mantine/core"; + +export default function CommonLoading() { + return ( +
+ +
+ ); +} diff --git a/apps/nextjs/src/app/[locale]/not-found.tsx b/apps/nextjs/src/app/[locale]/not-found.tsx new file mode 100644 index 000000000..2bffa0ff0 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/not-found.tsx @@ -0,0 +1,5 @@ +import { Center } from "@mantine/core"; + +export default function CommonNotFound() { + return
404
; +} diff --git a/apps/nextjs/src/app/page.tsx b/apps/nextjs/src/app/[locale]/page.tsx similarity index 100% rename from apps/nextjs/src/app/page.tsx rename to apps/nextjs/src/app/[locale]/page.tsx diff --git a/apps/nextjs/src/middleware.ts b/apps/nextjs/src/middleware.ts new file mode 100644 index 000000000..e5112f6ff --- /dev/null +++ b/apps/nextjs/src/middleware.ts @@ -0,0 +1,11 @@ +import type { NextRequest } from "next/server"; + +import { I18nMiddleware } from "@alparr/translation/middleware"; + +export function middleware(request: NextRequest) { + return I18nMiddleware(request); +} + +export const config = { + matcher: ["/((?!api|static|.*\\..*|_next|favicon.ico|robots.txt).*)"], +}; diff --git a/packages/translation/index.ts b/packages/translation/index.ts new file mode 100644 index 000000000..3bd16e178 --- /dev/null +++ b/packages/translation/index.ts @@ -0,0 +1 @@ +export * from "./src"; diff --git a/packages/translation/package.json b/packages/translation/package.json new file mode 100644 index 000000000..3c97abdc9 --- /dev/null +++ b/packages/translation/package.json @@ -0,0 +1,41 @@ +{ + "name": "@alparr/translation", + "private": true, + "version": "0.1.0", + "exports": { + ".": "./index.ts", + "./client": "./src/client.ts", + "./server": "./src/server.ts", + "./middleware": "./src/middleware.ts" + }, + "typesVersions": { + "*": { + "*": [ + "src/*" + ] + } + }, + "license": "MIT", + "scripts": { + "clean": "rm -rf .turbo node_modules", + "lint": "eslint .", + "format": "prettier --check . --ignore-path ../../.gitignore", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@alparr/eslint-config": "workspace:^0.2.0", + "@alparr/prettier-config": "workspace:^0.1.0", + "@alparr/tsconfig": "workspace:^0.1.0", + "eslint": "^8.53.0", + "typescript": "^5.3.3" + }, + "eslintConfig": { + "extends": [ + "@alparr/eslint-config/base" + ] + }, + "prettier": "@alparr/prettier-config", + "dependencies": { + "next-international": "^1.1.4" + } +} diff --git a/packages/translation/src/client.ts b/packages/translation/src/client.ts new file mode 100644 index 000000000..23c427063 --- /dev/null +++ b/packages/translation/src/client.ts @@ -0,0 +1,8 @@ +"use client"; + +import { createI18nClient } from "next-international/client"; + +import { languageMapping } from "./lang"; + +export const { useI18n, useScopedI18n, I18nProviderClient } = + createI18nClient(languageMapping()); diff --git a/packages/translation/src/index.ts b/packages/translation/src/index.ts new file mode 100644 index 000000000..c49f69163 --- /dev/null +++ b/packages/translation/src/index.ts @@ -0,0 +1,4 @@ +export const supportedLanguages = ["en", "de"] as const; +export type SupportedLanguage = (typeof supportedLanguages)[number]; + +export const defaultLocale = "en"; diff --git a/packages/translation/src/lang.ts b/packages/translation/src/lang.ts new file mode 100644 index 000000000..42f624c12 --- /dev/null +++ b/packages/translation/src/lang.ts @@ -0,0 +1,17 @@ +import { supportedLanguages } from "."; + +const enTranslations = () => import("./lang/en"); + +export const languageMapping = () => { + const mapping: Record = {}; + + for (const language of supportedLanguages) { + mapping[language] = () => + import(`./lang/${language}`) as ReturnType; + } + + return mapping as Record< + (typeof supportedLanguages)[number], + () => ReturnType + >; +}; diff --git a/packages/translation/src/lang/de.ts b/packages/translation/src/lang/de.ts new file mode 100644 index 000000000..7564746be --- /dev/null +++ b/packages/translation/src/lang/de.ts @@ -0,0 +1,29 @@ +export default { + user: { + page: { + login: { + title: "Melde dich bei deinem Konto an", + subtitle: "Willkommen zurück! Bitte gib deine Zugangsdaten ein", + }, + init: { + title: "Neue Alparr Installation", + subtitle: "Bitte erstelle den initialen Administrator Benutzer", + }, + }, + field: { + username: { + label: "Benutzername", + }, + password: { + label: "Passwort", + }, + passwordConfirm: { + label: "Passwort bestätigen", + }, + }, + action: { + login: "Anmelden", + create: "Benutzer erstellen", + }, + }, +} as const; diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts new file mode 100644 index 000000000..eb5b0bdd7 --- /dev/null +++ b/packages/translation/src/lang/en.ts @@ -0,0 +1,29 @@ +export default { + user: { + page: { + login: { + title: "Log in to your account", + subtitle: "Welcome back! Please enter your credentials", + }, + init: { + title: "New Alparr installation", + subtitle: "Please create the initial administator user", + }, + }, + field: { + username: { + label: "Username", + }, + password: { + label: "Password", + }, + passwordConfirm: { + label: "Confirm password", + }, + }, + action: { + login: "Login", + create: "Create user", + }, + }, +} as const; diff --git a/packages/translation/src/middleware.ts b/packages/translation/src/middleware.ts new file mode 100644 index 000000000..77d2a138d --- /dev/null +++ b/packages/translation/src/middleware.ts @@ -0,0 +1,9 @@ +import { createI18nMiddleware } from "next-international/middleware"; + +import { defaultLocale, supportedLanguages } from "."; + +export const I18nMiddleware = createI18nMiddleware({ + locales: supportedLanguages, + defaultLocale, + urlMappingStrategy: "rewrite", +}); diff --git a/packages/translation/src/server.ts b/packages/translation/src/server.ts new file mode 100644 index 000000000..c77b66f26 --- /dev/null +++ b/packages/translation/src/server.ts @@ -0,0 +1,6 @@ +import { createI18nServer } from "next-international/server"; + +import { languageMapping } from "./lang"; + +export const { getI18n, getScopedI18n, getStaticParams } = + createI18nServer(languageMapping()); diff --git a/packages/translation/tsconfig.json b/packages/translation/tsconfig.json new file mode 100644 index 000000000..5d2933359 --- /dev/null +++ b/packages/translation/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@alparr/tsconfig/base.json", + "compilerOptions": { + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" + }, + "include": ["*.ts", "src"], + "exclude": ["node_modules"] +} diff --git a/packages/validation/src/user.ts b/packages/validation/src/user.ts index 9ebb3ee72..e23f47bc5 100644 --- a/packages/validation/src/user.ts +++ b/packages/validation/src/user.ts @@ -7,10 +7,10 @@ export const initUserSchema = z .object({ username: usernameSchema, password: passwordSchema, - repeatPassword: z.string(), + confirmPassword: z.string(), }) - .refine((data) => data.password === data.repeatPassword, { - path: ["repeatPassword"], + .refine((data) => data.password === data.confirmPassword, { + path: ["confirmPassword"], message: "Passwords do not match", }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2686db59d..7e9571b4f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: '@alparr/db': specifier: workspace:^0.1.0 version: link:../../packages/db + '@alparr/translation': + specifier: workspace:^ + version: link:../../packages/translation '@alparr/ui': specifier: workspace:^0.1.0 version: link:../../packages/ui @@ -301,6 +304,28 @@ importers: specifier: ^5.3.3 version: 5.3.3 + packages/translation: + dependencies: + next-international: + specifier: ^1.1.4 + version: 1.1.4 + devDependencies: + '@alparr/eslint-config': + specifier: workspace:^0.2.0 + version: link:../../tooling/eslint + '@alparr/prettier-config': + specifier: workspace:^0.1.0 + version: link:../../tooling/prettier + '@alparr/tsconfig': + specifier: workspace:^0.1.0 + version: link:../../tooling/typescript + eslint: + specifier: ^8.53.0 + version: 8.53.0 + typescript: + specifier: ^5.3.3 + version: 5.3.3 + packages/ui: dependencies: '@mantine/core': @@ -4395,6 +4420,10 @@ packages: side-channel: 1.0.4 dev: false + /international-types@0.8.1: + resolution: {integrity: sha512-tajBCAHo4I0LIFlmQ9ZWfjMWVyRffzuvfbXCd6ssFt5u1Zw15DN0UBpVTItXdNa1ls+cpQt3Yw8+TxsfGF8JcA==} + dev: false + /invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} dependencies: @@ -5070,6 +5099,14 @@ packages: react: 18.2.0 dev: false + /next-international@1.1.4: + resolution: {integrity: sha512-peIJXXEC5lM7zZONCgN1uUxCkIHpSW1pZuHoRTp9ND14K7CDdHajDMz9RTxVCmQUGWXSaqruM6XVAuq4d+Gpxg==} + dependencies: + client-only: 0.0.1 + international-types: 0.8.1 + server-only: 0.0.1 + dev: false + /next-tick@1.1.0: resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==} dev: true @@ -6122,6 +6159,10 @@ packages: upper-case-first: 1.1.2 dev: true + /server-only@0.0.1: + resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + dev: false + /set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} dev: false