feat: wire i18n services and shared UI

This commit is contained in:
poyrazavsever
2026-07-19 03:06:56 +03:00
parent 805beccf95
commit 826410f53e
11 changed files with 1330 additions and 0 deletions
+51
View File
@@ -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<I18nContextValue | null>(null);
export function I18nProvider({
children,
locale,
messages,
}: {
children: ReactNode;
locale: string;
messages: Record<string, string>;
}) {
const value = useMemo(
() => createTranslatorFromMessages(locale, messages),
[locale, messages],
);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
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;
}
+58
View File
@@ -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 (
<div className="space-y-2">
<Label htmlFor="auth-locale">{label}</Label>
<Select value={currentLocale} onValueChange={handleChange} disabled={pending}>
<SelectTrigger id="auth-locale" aria-label={label}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{locales.map((locale) => (
<SelectItem key={locale.code} value={locale.code}>
{locale.nativeName || locale.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
+117
View File
@@ -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<string, Record<string, string | null | undefined>>;
type LocalizedFieldsProps = {
idPrefix: string;
defaultLocale: string;
locales: LocalizedFieldLocale[];
fields: ContentTranslationField[];
values?: LocalizedFieldValues | null;
fallbackValues?: Record<string, string | null | undefined>;
};
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 (
<div className="grid gap-4 rounded-sm border border-border bg-muted/10 p-3">
<div className="tiny-scrollbar flex gap-2 overflow-x-auto pb-1">
{orderedLocales.map((locale) => (
<Button
key={locale.code}
type="button"
variant={activeLocale === locale.code ? "default" : "secondary"}
effect="shine"
size="sm"
className="shrink-0 gap-2"
onClick={() => setActiveLocale(locale.code)}
>
{locale.nativeName || locale.code}
{locale.code === defaultLocale ? <Badge variant="secondary">varsayılan</Badge> : null}
{missingRequiredLocales.has(locale.code) ? <span aria-hidden="true" className="text-amber-500"></span> : null}
</Button>
))}
</div>
{orderedLocales.map((locale) => (
<div key={locale.code} className={activeLocale === locale.code ? "grid gap-4" : "hidden"}>
{fields.map((field) => {
const id = `${idPrefix}-${locale.code}-${field.name}`;
const defaultValue =
values?.[locale.code]?.[field.name] ??
(locale.code === defaultLocale ? fallbackValues?.[field.name] : "") ??
"";
return (
<div key={field.name} className="grid gap-2">
<Label htmlFor={id}>
{field.label}
{field.required && locale.code === defaultLocale ? <span className="text-destructive"> *</span> : null}
</Label>
{field.kind === "textarea" ? (
<Textarea
id={id}
name={contentInputName(locale.code, field.name)}
defaultValue={String(defaultValue ?? "")}
maxLength={field.maxLength}
required={field.required && locale.code === defaultLocale}
placeholder={field.placeholder}
rows={field.name === "content" ? 8 : 3}
/>
) : (
<Input
id={id}
name={contentInputName(locale.code, field.name)}
defaultValue={String(defaultValue ?? "")}
maxLength={field.maxLength}
required={field.required && locale.code === defaultLocale}
placeholder={field.placeholder}
/>
)}
</div>
);
})}
</div>
))}
</div>
);
}