diff --git a/app/api/i18n/locale/route.ts b/app/api/i18n/locale/route.ts new file mode 100644 index 0000000..6125f5f --- /dev/null +++ b/app/api/i18n/locale/route.ts @@ -0,0 +1,53 @@ +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; +import { getSqliteConnection } from "@/server/db/client"; +import { instanceLocales } from "@/server/db/schema"; +import { DomainError } from "@/server/domain/errors"; +import { buildLocaleCookie, normalizeLocaleCode } from "@/server/i18n/locale"; +import { eq } from "drizzle-orm"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(request: Request) { + try { + const payload = await request.json(); + const locale = normalizeLocaleCode(typeof payload.locale === "string" ? payload.locale : null); + if (!locale) { + throw new DomainError("VALIDATION_ERROR", "validation.invalidLocale", { + messageKey: "validation.invalidLocale", + }); + } + + const row = getSqliteConnection().db + .select({ code: instanceLocales.code, status: instanceLocales.status }) + .from(instanceLocales) + .where(eq(instanceLocales.code, locale)) + .get(); + if (!row || row.status === "archived") { + throw new DomainError("UNSUPPORTED_LOCALE", "validation.unsupportedLocale", { + messageKey: "validation.unsupportedLocale", + }); + } + + (await cookies()).set(buildLocaleCookie(row.code)); + return NextResponse.json({ ok: true, data: { locale: row.code } }); + } catch (error) { + const normalized = error instanceof DomainError + ? error + : new DomainError("VALIDATION_ERROR", "validation.unsupportedLocale", { + messageKey: "validation.unsupportedLocale", + }); + return NextResponse.json( + { + ok: false, + error: { + code: normalized.code, + message: normalized.message, + messageKey: normalized.details?.messageKey, + }, + }, + { status: normalized.status }, + ); + } +} diff --git a/components/i18n/i18n-provider.tsx b/components/i18n/i18n-provider.tsx new file mode 100644 index 0000000..76399b0 --- /dev/null +++ b/components/i18n/i18n-provider.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { + createContext, + useContext, + useMemo, + type ReactNode, +} from "react"; +import { + createTranslatorFromMessages, + type Translator, + type TranslationValues, +} from "@/lib/i18n"; + +type I18nContextValue = Translator; + +const I18nContext = createContext(null); + +export function I18nProvider({ + children, + locale, + messages, +}: { + children: ReactNode; + locale: string; + messages: Record; +}) { + const value = useMemo( + () => createTranslatorFromMessages(locale, messages), + [locale, messages], + ); + + return {children}; +} + +export function useTranslations() { + const value = useContext(I18nContext); + if (!value) { + throw new Error("useTranslations must be used within I18nProvider."); + } + + return (key: string, values?: TranslationValues) => value.t(key, values); +} + +export function useI18n() { + const value = useContext(I18nContext); + if (!value) { + throw new Error("useI18n must be used within I18nProvider."); + } + return value; +} diff --git a/components/i18n/locale-select-form.tsx b/components/i18n/locale-select-form.tsx new file mode 100644 index 0000000..887b119 --- /dev/null +++ b/components/i18n/locale-select-form.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { Label } from "poyraz-ui/atoms"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "poyraz-ui/molecules"; + +type LocaleOption = { + code: string; + nativeName: string; + name: string; +}; + +type LocaleSelectFormProps = { + label: string; + value: string; + locales: LocaleOption[]; +}; + +export function LocaleSelectForm({ label, value, locales }: LocaleSelectFormProps) { + const [currentLocale, setCurrentLocale] = useState(value); + const [pending, startTransition] = useTransition(); + + function handleChange(locale: string) { + setCurrentLocale(locale); + startTransition(async () => { + await fetch("/api/i18n/locale", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ locale }), + }); + window.location.reload(); + }); + } + + return ( +
+ + +
+ ); +} diff --git a/components/i18n/localized-fields.tsx b/components/i18n/localized-fields.tsx new file mode 100644 index 0000000..47e42db --- /dev/null +++ b/components/i18n/localized-fields.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Badge, Button, Input, Label, Textarea } from "poyraz-ui/atoms"; +import { contentInputName, type ContentTranslationField } from "@/lib/i18n/content"; + +export type LocalizedFieldLocale = { + code: string; + nativeName: string; + status?: string; +}; + +export type LocalizedFieldValues = Record>; + +type LocalizedFieldsProps = { + idPrefix: string; + defaultLocale: string; + locales: LocalizedFieldLocale[]; + fields: ContentTranslationField[]; + values?: LocalizedFieldValues | null; + fallbackValues?: Record; +}; + +export function LocalizedFields({ + idPrefix, + defaultLocale, + locales, + fields, + values, + fallbackValues, +}: LocalizedFieldsProps) { + const orderedLocales = useMemo(() => { + const localeMap = new Map(locales.map((locale) => [locale.code, locale])); + const defaultLocaleRecord = localeMap.get(defaultLocale); + return [ + ...(defaultLocaleRecord ? [defaultLocaleRecord] : []), + ...locales.filter((locale) => locale.code !== defaultLocale), + ]; + }, [defaultLocale, locales]); + const [activeLocale, setActiveLocale] = useState(orderedLocales[0]?.code ?? defaultLocale); + const missingRequiredLocales = new Set( + orderedLocales + .filter((locale) => + fields.some((field) => { + if (!field.required) return false; + const value = values?.[locale.code]?.[field.name] ?? ( + locale.code === defaultLocale ? fallbackValues?.[field.name] : null + ); + return !String(value ?? "").trim(); + }), + ) + .map((locale) => locale.code), + ); + + return ( +
+
+ {orderedLocales.map((locale) => ( + + ))} +
+ + {orderedLocales.map((locale) => ( +
+ {fields.map((field) => { + const id = `${idPrefix}-${locale.code}-${field.name}`; + const defaultValue = + values?.[locale.code]?.[field.name] ?? + (locale.code === defaultLocale ? fallbackValues?.[field.name] : "") ?? + ""; + + return ( +
+ + {field.kind === "textarea" ? ( +