feat: wire i18n services and shared UI
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user