mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +00:00
frontend: add Somali, Arabic (RTL) & German languages
Add three UI locales (so/ar/de) with full ~1,660-key translations alongside en/fr, selectable in Settings → Profile. Arabic gets full right-to-left support: - config.ts registers the locales and exports a `dirFor` helper; an inline <head> script in layout.tsx sets <html dir/lang> before first paint (no RTL flash), and i18n-provider keeps them in sync on language change. - ~160 physical direction utilities converted to logical (ms/me/ps/pe/ start/end/text-start/text-end); directional chevrons/arrows get rtl:rotate-180; chat-bubble align variants fixed to logical. - IBM Plex Sans Arabic appended to the sans/heading font stacks for per-character Arabic fallback. - Language persists to the backend user_settings and re-applies on sign-in so it roams across devices (localStorage stays the offline source of truth). - New scripts/check-locales.mjs (npm run check-locales) enforces key/placeholder parity and Arabic CLDR plural completeness. Bump to 0.3.0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,25 @@ for how releases are cut and published.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.3.0] — 2026-07-02
|
||||
|
||||
### Added
|
||||
- **Three new interface languages** — Somali (`so`), Arabic (`ar`) and German
|
||||
(`de`) join English and French, selectable in Settings → Profile → Language.
|
||||
Each locale carries a full translation of the ~1,660 UI strings, with native
|
||||
names shown in the selector (Soomaali, العربية, Deutsch).
|
||||
- **Right-to-left (RTL) support** — selecting Arabic sets `dir="rtl"` on the
|
||||
document (applied before first paint via an inline script, so no flash), flips
|
||||
physical spacing/alignment to logical CSS utilities, mirrors directional icons,
|
||||
and loads an Arabic-capable typeface (IBM Plex Sans Arabic) appended to the
|
||||
font stack for per-character fallback.
|
||||
- **Language roams across devices** — the chosen language is persisted to the
|
||||
per-user `user_settings` preferences and re-applied on sign-in, with
|
||||
localStorage remaining the offline source of truth.
|
||||
- **`frontend/scripts/check-locales.mjs`** (+ `npm run check-locales`) — a parity
|
||||
check that fails on missing/extra keys or `{{placeholder}}` mismatches across
|
||||
locales and warns when Arabic count-keys lack the full CLDR plural forms.
|
||||
|
||||
## [0.2.5] — 2026-07-01
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "temetro-backend",
|
||||
"version": "0.2.5",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "temetro backend — Express + Postgres API with Better Auth (email/password, organizations) and org-scoped patient records.",
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-sans);
|
||||
/* Append the Arabic face so Arabic codepoints fall through per-character even
|
||||
in LTR locales; Latin text still renders in Inter. See app/layout.tsx. */
|
||||
--font-sans: var(--font-sans), var(--font-arabic);
|
||||
--font-mono: var(--font-mono);
|
||||
--font-heading: var(--font-heading);
|
||||
--font-heading: var(--font-heading), var(--font-arabic);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
|
||||
+30
-4
@@ -1,5 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist_Mono, Inter } from "next/font/google";
|
||||
import { Geist_Mono, IBM_Plex_Sans_Arabic, Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
@@ -10,6 +10,27 @@ import { ToastProvider } from "@/components/ui/toast";
|
||||
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
|
||||
const interHeading = Inter({ subsets: ["latin"], variable: "--font-heading" });
|
||||
const geistMono = Geist_Mono({ subsets: ["latin"], variable: "--font-mono" });
|
||||
// Arabic-capable fallback: Inter has no Arabic glyphs, so we append this to the
|
||||
// sans/heading stacks (see globals.css) for per-character fallback in every
|
||||
// locale, and rely on it fully when dir="rtl". Not a variable font — pin weights.
|
||||
const plexArabic = IBM_Plex_Sans_Arabic({
|
||||
subsets: ["arabic"],
|
||||
weight: ["400", "500", "600", "700"],
|
||||
variable: "--font-arabic",
|
||||
});
|
||||
|
||||
// Runs before first paint: mirror lib/i18n/config.ts `dirFor` so an Arabic user
|
||||
// gets dir="rtl" immediately instead of a flash of LTR. Detection order matches
|
||||
// i18next-browser-languagedetector (localStorage key "i18nextLng", then the
|
||||
// browser language). suppressHydrationWarning on <html> ignores the attr diff.
|
||||
const setInitialDir = `
|
||||
(function(){try{
|
||||
var l=localStorage.getItem("i18nextLng")||navigator.language||"en";
|
||||
var e=document.documentElement;
|
||||
e.lang=l;
|
||||
e.dir=l.indexOf("ar")===0?"rtl":"ltr";
|
||||
}catch(_){}})();
|
||||
`;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "temetro — AI assistant for clinicians",
|
||||
@@ -33,17 +54,22 @@ export default function RootLayout({
|
||||
inter.variable,
|
||||
interHeading.variable,
|
||||
geistMono.variable,
|
||||
plexArabic.variable,
|
||||
"font-sans"
|
||||
)}
|
||||
>
|
||||
{/* suppressHydrationWarning: next-themes sets the theme class on <html>
|
||||
before hydration, and browser extensions (e.g. ColorZilla's
|
||||
cz-shortcut-listen) mutate <body>. Only ignores attribute diffs on
|
||||
those elements, not their children. */}
|
||||
before hydration, the dir script below sets lang/dir, and browser
|
||||
extensions (e.g. ColorZilla's cz-shortcut-listen) mutate <body>. Only
|
||||
ignores attribute diffs on those elements, not their children. */}
|
||||
<body
|
||||
className="h-dvh overflow-hidden flex flex-col"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<script
|
||||
// Sets <html dir/lang> before paint; see setInitialDir above.
|
||||
dangerouslySetInnerHTML={{ __html: setInitialDir }}
|
||||
/>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
|
||||
@@ -104,7 +104,7 @@ function DetailRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="shrink-0 text-muted-foreground text-xs">{label}</span>
|
||||
<span className="text-right text-foreground text-sm">{value}</span>
|
||||
<span className="text-end text-foreground text-sm">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -212,7 +212,7 @@ export function ActivityView() {
|
||||
|
||||
<button
|
||||
className={cn(
|
||||
"-mx-2 flex-1 rounded-lg px-2 py-1 text-left transition-colors hover:bg-accent/40",
|
||||
"-mx-2 flex-1 rounded-lg px-2 py-1 text-start transition-colors hover:bg-accent/40",
|
||||
isLast ? "pb-1" : "mb-5",
|
||||
)}
|
||||
onClick={() => setSelected(entry)}
|
||||
|
||||
@@ -37,7 +37,7 @@ export function TrendCard({
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="w-full text-left"
|
||||
className="w-full text-start"
|
||||
disabled={!hasData}
|
||||
onClick={() => setOpen(true)}
|
||||
type="button"
|
||||
|
||||
@@ -314,9 +314,9 @@ export function AppointmentsView() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 start-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="w-full pl-9 sm:w-64"
|
||||
className="w-full ps-9 sm:w-64"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t("appointments.searchPlaceholder")}
|
||||
value={query}
|
||||
|
||||
@@ -145,7 +145,7 @@ export function CalendarDialog({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ChevronLeft />
|
||||
<ChevronLeft className="rtl:rotate-180" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Next month"
|
||||
@@ -154,7 +154,7 @@ export function CalendarDialog({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ChevronRight />
|
||||
<ChevronRight className="rtl:rotate-180" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -182,7 +182,7 @@ export function CalendarDialog({
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"flex min-h-22 flex-col gap-1 rounded-lg border p-1.5 text-left align-top transition-colors hover:bg-accent/50",
|
||||
"flex min-h-22 flex-col gap-1 rounded-lg border p-1.5 text-start align-top transition-colors hover:bg-accent/50",
|
||||
inMonth
|
||||
? "bg-card/30"
|
||||
: "bg-transparent text-muted-foreground/40",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { type ReactNode, useEffect, useRef } from "react";
|
||||
|
||||
import { useAiAccess } from "@/lib/ai-policy";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { applyStoredLanguage } from "@/lib/language";
|
||||
import { canAccessRoute, defaultLandingFor, useActiveRole } from "@/lib/roles";
|
||||
|
||||
// Authoritative client-side gate for the app shell. Requires a session and an
|
||||
@@ -24,6 +25,16 @@ export function AppAuthGuard({ children }: { children: ReactNode }) {
|
||||
const hasUser = Boolean(session?.user);
|
||||
const activeOrgId = session?.session?.activeOrganizationId ?? null;
|
||||
|
||||
// Adopt the language saved on the backend once signed in, so the UI language
|
||||
// roams across devices. Best-effort and one-shot; localStorage stays the
|
||||
// offline source of truth.
|
||||
const languageSynced = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!hasUser || languageSynced.current) return;
|
||||
languageSynced.current = true;
|
||||
void applyStoredLanguage();
|
||||
}, [hasUser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPending) return;
|
||||
if (!hasUser) {
|
||||
|
||||
@@ -269,7 +269,7 @@ export function ActionPreviewCard({
|
||||
</span>
|
||||
{editable ? (
|
||||
<Button
|
||||
className="ml-auto"
|
||||
className="ms-auto"
|
||||
onClick={() => setEditOpen(true)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
|
||||
@@ -60,7 +60,7 @@ export function AiSetupNotice() {
|
||||
</div>
|
||||
<button
|
||||
aria-label={t("chat.setupNotice.dismiss")}
|
||||
className="-mr-1 shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
className="-me-1 shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={() => setDismissed(true)}
|
||||
type="button"
|
||||
>
|
||||
|
||||
@@ -115,7 +115,7 @@ export function BatchActionPreviewCard({
|
||||
<span className="font-medium text-sm">
|
||||
{t("chat.actionCard.batch.title", { count: items.length })}
|
||||
</span>
|
||||
<Badge className="ml-auto gap-1" variant="secondary">
|
||||
<Badge className="ms-auto gap-1" variant="secondary">
|
||||
<Sparkles className="size-3" />
|
||||
AI
|
||||
</Badge>
|
||||
|
||||
@@ -104,9 +104,9 @@ export function ChatHistoryPanel() {
|
||||
{t("chat.history.startNew")}
|
||||
</Button>
|
||||
<div className="relative">
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 left-2.5 size-4 text-muted-foreground" />
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 start-2.5 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-8"
|
||||
className="ps-8"
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("chat.history.search")}
|
||||
value={query}
|
||||
@@ -123,7 +123,7 @@ export function ChatHistoryPanel() {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"group flex items-center gap-2 rounded-md px-2 py-2 text-left text-sm transition-colors hover:bg-accent",
|
||||
"group flex items-center gap-2 rounded-md px-2 py-2 text-start text-sm transition-colors hover:bg-accent",
|
||||
active
|
||||
? "bg-accent text-foreground"
|
||||
: "text-muted-foreground",
|
||||
|
||||
@@ -242,7 +242,7 @@ export function ChatInput({
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className={cn(contextPill, "ml-0.5")}
|
||||
className={cn(contextPill, "ms-0.5")}
|
||||
onClick={() => {
|
||||
setAddKey((k) => k + 1);
|
||||
setAddOpen(true);
|
||||
@@ -258,7 +258,7 @@ export function ChatInput({
|
||||
<ModePicker
|
||||
mode={mode}
|
||||
onModeChange={onModeChange}
|
||||
triggerClassName={cn(pillButton, "mr-1")}
|
||||
triggerClassName={cn(pillButton, "me-1")}
|
||||
/>
|
||||
<button
|
||||
aria-label={
|
||||
|
||||
@@ -421,7 +421,7 @@ export function ChatPanel() {
|
||||
</div>
|
||||
<button
|
||||
aria-label={t("chat.error.dismiss")}
|
||||
className="-mr-1 shrink-0 rounded-md p-1 text-destructive-foreground/70 transition-colors hover:bg-destructive/10 hover:text-destructive-foreground"
|
||||
className="-me-1 shrink-0 rounded-md p-1 text-destructive-foreground/70 transition-colors hover:bg-destructive/10 hover:text-destructive-foreground"
|
||||
onClick={() => setErrorDismissed(true)}
|
||||
type="button"
|
||||
>
|
||||
|
||||
@@ -196,7 +196,7 @@ export function ImportPreviewCard({
|
||||
<span className="text-sm font-medium">{t("chat.importCard.title")}</span>
|
||||
{status === "pending" && records.length > 0 ? (
|
||||
<Button
|
||||
className="ml-auto"
|
||||
className="ms-auto"
|
||||
onClick={() => setReviewOpen(true)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@@ -289,7 +289,7 @@ export function ImportPreviewCard({
|
||||
t("chat.importCard.unnamed");
|
||||
return (
|
||||
<button
|
||||
className="flex items-start gap-2 rounded-xl border bg-card/30 p-3 text-left transition-colors hover:bg-accent"
|
||||
className="flex items-start gap-2 rounded-xl border bg-card/30 p-3 text-start transition-colors hover:bg-accent"
|
||||
key={index}
|
||||
onClick={() => setEditingIndex(index)}
|
||||
type="button"
|
||||
|
||||
@@ -16,7 +16,7 @@ export function InventoryListCard({ items }: { items: InventoryItem[] }) {
|
||||
<div className="flex items-center gap-2 border-b px-4 py-3">
|
||||
<Boxes className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium text-sm">{t("chat.lists.inventory")}</span>
|
||||
<Badge className="ml-auto" variant="secondary">
|
||||
<Badge className="ms-auto" variant="secondary">
|
||||
{items.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -71,11 +71,11 @@ const statusVariant: Record<Patient["status"], BadgeVariant> = {
|
||||
// plus a subtle clickable affordance (they open a detail dialog). Compact cards
|
||||
// size to their own (short) content — see `items-start` in PatientResult.
|
||||
const rowCard =
|
||||
"w-72 shrink-0 cursor-pointer gap-0 text-left outline-none transition hover:bg-accent/30 hover:ring-foreground/20 focus-visible:ring-2 focus-visible:ring-ring";
|
||||
"w-72 shrink-0 cursor-pointer gap-0 text-start outline-none transition hover:bg-accent/30 hover:ring-foreground/20 focus-visible:ring-2 focus-visible:ring-ring";
|
||||
|
||||
// Same footprint as `rowCard` but with no clickable affordance — used when a
|
||||
// card has nothing extra to reveal, so it shouldn't promise "Click for more".
|
||||
const rowCardStatic = "w-72 shrink-0 gap-0 text-left";
|
||||
const rowCardStatic = "w-72 shrink-0 gap-0 text-start";
|
||||
|
||||
// COSS Card has no `size` variant; recreate the old compact ("sm") density by
|
||||
// tightening the inner section padding from p-6 → p-4 via data-slot selectors.
|
||||
@@ -196,7 +196,7 @@ function ExpandableCard({
|
||||
{children}
|
||||
<div className="flex items-center gap-1 px-4 pt-2 pb-3 text-muted-foreground text-xs">
|
||||
{t("patientCard.clickForMore")}
|
||||
<ArrowRight className="size-3" />
|
||||
<ArrowRight className="size-3 rtl:rotate-180" />
|
||||
</div>
|
||||
</DialogTrigger>
|
||||
<DialogPopup className="max-h-[80dvh] sm:max-w-lg">
|
||||
|
||||
@@ -724,7 +724,7 @@ export function PatientFormDialog({
|
||||
|
||||
<DialogFooter className="flex-col items-stretch gap-2 sm:flex-row sm:items-center">
|
||||
{error && (
|
||||
<p className="text-sm text-destructive sm:mr-auto">{error}</p>
|
||||
<p className="text-sm text-destructive sm:me-auto">{error}</p>
|
||||
)}
|
||||
<DialogClose render={<Button type="button" variant="outline" />}>
|
||||
{t("patientForm.cancel")}
|
||||
|
||||
@@ -31,7 +31,7 @@ function Shell({
|
||||
<div className="flex items-center gap-2 border-b px-4 py-3">
|
||||
<Icon className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium text-sm">{title}</span>
|
||||
<Badge className="ml-auto" variant="secondary">
|
||||
<Badge className="ms-auto" variant="secondary">
|
||||
{rows.length}
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -123,7 +123,7 @@ export function AppointmentListCard({
|
||||
{range ? (
|
||||
<span className="text-muted-foreground text-xs">{range}</span>
|
||||
) : null}
|
||||
<Badge className="ml-auto" variant="secondary">
|
||||
<Badge className="ms-auto" variant="secondary">
|
||||
{appointments.length}
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -157,7 +157,7 @@ export function AppointmentListCard({
|
||||
</span>
|
||||
<Button render={<Link href={href} />} size="sm" variant="ghost">
|
||||
{t("chat.lists.viewInCalendar")}
|
||||
<ChevronRight className="size-4" />
|
||||
<ChevronRight className="size-4 rtl:rotate-180" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import type * as React from "react";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
|
||||
import i18n from "@/lib/i18n/config";
|
||||
import i18n, { dirFor } from "@/lib/i18n/config";
|
||||
|
||||
export function I18nProvider({ children }: { children: React.ReactNode }) {
|
||||
// Keep <html lang/dir> in sync with the active language. The inline script in
|
||||
// app/layout.tsx sets these before first paint (avoiding an RTL flash); this
|
||||
// effect keeps them correct after hydration and on every language switch.
|
||||
useEffect(() => {
|
||||
const apply = (lng: string) => {
|
||||
const root = document.documentElement;
|
||||
root.lang = lng;
|
||||
root.dir = dirFor(lng);
|
||||
};
|
||||
apply(i18n.resolvedLanguage ?? i18n.language);
|
||||
i18n.on("languageChanged", apply);
|
||||
return () => {
|
||||
i18n.off("languageChanged", apply);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>;
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ export function InvoiceDetailSheet({
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
{invoice.number}
|
||||
<AiBadge source={invoice.source} />
|
||||
<Badge className="ml-auto" variant={statusVariant[invoice.status]}>
|
||||
<Badge className="ms-auto" variant={statusVariant[invoice.status]}>
|
||||
{t(`invoices.status.${invoice.status}`)}
|
||||
</Badge>
|
||||
</SheetTitle>
|
||||
@@ -222,7 +222,7 @@ export function InvoiceDetailSheet({
|
||||
<span className="shrink-0 text-muted-foreground text-xs tabular-nums">
|
||||
{li.quantity} × {formatMoney(li.unitPrice)}
|
||||
</span>
|
||||
<span className="w-20 shrink-0 text-right font-medium text-foreground tabular-nums">
|
||||
<span className="w-20 shrink-0 text-end font-medium text-foreground tabular-nums">
|
||||
{formatMoney(li.quantity * li.unitPrice)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -135,9 +135,9 @@ export function InvoicesView() {
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div className="relative">
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 start-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="w-full pl-9 sm:w-64"
|
||||
className="w-full ps-9 sm:w-64"
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
setPage(1);
|
||||
@@ -180,7 +180,7 @@ export function InvoicesView() {
|
||||
<div className="divide-y divide-border overflow-hidden rounded-2xl border bg-card/30">
|
||||
{pageRows.map((inv) => (
|
||||
<button
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-accent/50"
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-start transition-colors hover:bg-accent/50"
|
||||
key={inv.id}
|
||||
onClick={() => openInvoice(inv)}
|
||||
type="button"
|
||||
|
||||
@@ -92,7 +92,7 @@ export function LabIntegrationCard({
|
||||
{t("integrations.fhir.cardTitle")}
|
||||
</h2>
|
||||
<Badge
|
||||
className="ml-auto"
|
||||
className="ms-auto"
|
||||
variant={
|
||||
config.status === "connected"
|
||||
? "secondary"
|
||||
@@ -141,9 +141,9 @@ export function LabIntegrationCard({
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="relative">
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 start-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
className="ps-9"
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("integrations.fhir.searchPlaceholder")}
|
||||
value={query}
|
||||
@@ -153,7 +153,7 @@ export function LabIntegrationCard({
|
||||
<div className="flex flex-col gap-1">
|
||||
{matches.map((p) => (
|
||||
<button
|
||||
className="flex items-center gap-3 rounded-lg px-2 py-2 text-left transition-colors hover:bg-accent"
|
||||
className="flex items-center gap-3 rounded-lg px-2 py-2 text-start transition-colors hover:bg-accent"
|
||||
key={p.fileNumber}
|
||||
onClick={() => setSelected(p)}
|
||||
type="button"
|
||||
|
||||
@@ -336,7 +336,7 @@ function AddResultDialog({
|
||||
{t("lab.addResult.patient")}
|
||||
</span>
|
||||
<div className="relative">
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 start-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
aria-activedescendant={
|
||||
matches[activeIndex]
|
||||
@@ -344,7 +344,7 @@ function AddResultDialog({
|
||||
: undefined
|
||||
}
|
||||
autoFocus
|
||||
className="pl-9"
|
||||
className="ps-9"
|
||||
onChange={(event) => {
|
||||
setPatientQuery(event.target.value);
|
||||
setActiveIndex(0);
|
||||
@@ -359,7 +359,7 @@ function AddResultDialog({
|
||||
{matches.map((p, index) => (
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-2 py-2 text-left transition-colors",
|
||||
"flex items-center gap-3 rounded-lg px-2 py-2 text-start transition-colors",
|
||||
index === activeIndex
|
||||
? "bg-accent"
|
||||
: "hover:bg-accent",
|
||||
@@ -650,7 +650,7 @@ export function LabView() {
|
||||
}
|
||||
onClick={() => toggle(task.id)}
|
||||
/>
|
||||
<CollapsibleTrigger className="group flex min-w-0 flex-1 items-center gap-3 text-left">
|
||||
<CollapsibleTrigger className="group flex min-w-0 flex-1 items-center gap-3 text-start">
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span
|
||||
className={cn(
|
||||
@@ -679,7 +679,7 @@ export function LabView() {
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
<CollapsibleContent>
|
||||
<div className="space-y-3 px-4 pb-4 pl-12 text-sm">
|
||||
<div className="space-y-3 px-4 pb-4 ps-12 text-sm">
|
||||
<p
|
||||
className={cn(
|
||||
"whitespace-pre-wrap",
|
||||
|
||||
@@ -124,7 +124,7 @@ export function LoginForm({
|
||||
{t("auth.login.passwordLabel")}
|
||||
</FieldLabel>
|
||||
<Link
|
||||
className="ml-auto inline-block text-sm underline-offset-4 hover:underline"
|
||||
className="ms-auto inline-block text-sm underline-offset-4 hover:underline"
|
||||
href="/forgot-password"
|
||||
>
|
||||
{t("auth.login.forgotPassword")}
|
||||
|
||||
@@ -285,10 +285,10 @@ export function MeetingRoom({
|
||||
</DialogHeader>
|
||||
<DialogPanel className="flex flex-col gap-2">
|
||||
<div className="relative">
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 start-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label={t("meetings.invite.search")}
|
||||
className="pl-9"
|
||||
className="ps-9"
|
||||
onChange={(e) => setMemberQuery(e.target.value)}
|
||||
placeholder={t("meetings.invite.search")}
|
||||
size="sm"
|
||||
|
||||
@@ -246,13 +246,13 @@ export function MeetingsView() {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group flex w-full items-center gap-1 rounded-lg pr-1 transition-colors hover:bg-accent/50",
|
||||
"group flex w-full items-center gap-1 rounded-lg pe-1 transition-colors hover:bg-accent/50",
|
||||
activeRoom?.id === room.id && "bg-accent hover:bg-accent",
|
||||
)}
|
||||
key={room.id}
|
||||
>
|
||||
<button
|
||||
className="flex min-w-0 flex-1 items-center gap-2.5 rounded-lg px-2 py-2 text-left"
|
||||
className="flex min-w-0 flex-1 items-center gap-2.5 rounded-lg px-2 py-2 text-start"
|
||||
onClick={() => setActiveRoom(room)}
|
||||
type="button"
|
||||
>
|
||||
@@ -352,7 +352,7 @@ export function MeetingsView() {
|
||||
<div className="flex flex-col gap-1">
|
||||
{upcoming.map((e) => (
|
||||
<button
|
||||
className="flex flex-col gap-0.5 rounded-xl border bg-card px-2.5 py-2 text-left transition-colors hover:bg-accent/50"
|
||||
className="flex flex-col gap-0.5 rounded-xl border bg-card px-2.5 py-2 text-start transition-colors hover:bg-accent/50"
|
||||
key={e.id}
|
||||
onClick={() => setSelectedDay(new Date(`${e.date}T00:00:00`))}
|
||||
type="button"
|
||||
|
||||
@@ -159,7 +159,7 @@ export function ScheduleMeetingDialog({
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-accent/50",
|
||||
"flex items-center gap-3 rounded-lg px-2 py-1.5 text-start transition-colors hover:bg-accent/50",
|
||||
on && "bg-accent",
|
||||
)}
|
||||
key={m.id}
|
||||
|
||||
@@ -28,7 +28,7 @@ function Row({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<dt className="text-muted-foreground text-sm">{label}</dt>
|
||||
<dd className="text-right text-foreground text-sm">{value}</dd>
|
||||
<dd className="text-end text-foreground text-sm">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ function SentAttachment({ att }: { att: MessageAttachment }) {
|
||||
<AttachmentContent>
|
||||
<AttachmentTitle>{att.fileName}</AttachmentTitle>
|
||||
</AttachmentContent>
|
||||
<AttachmentActions className="pr-1.5">
|
||||
<AttachmentActions className="pe-1.5">
|
||||
<AttachmentAction
|
||||
aria-label={t("messages.attach.download")}
|
||||
onClick={() => {
|
||||
@@ -491,10 +491,10 @@ export function MessagesView() {
|
||||
</div>
|
||||
<div className="border-border border-b px-3 py-2">
|
||||
<div className="relative">
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 start-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label={t("messages.searchPlaceholder")}
|
||||
className="pl-9"
|
||||
className="ps-9"
|
||||
onChange={(e) => setInboxQuery(e.target.value)}
|
||||
placeholder={t("messages.searchPlaceholder")}
|
||||
size="sm"
|
||||
@@ -520,7 +520,7 @@ export function MessagesView() {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center gap-1 rounded-lg pr-2 transition-colors hover:bg-accent/50",
|
||||
"flex w-full items-center gap-1 rounded-lg pe-2 transition-colors hover:bg-accent/50",
|
||||
selected?.id === c.id && "bg-accent hover:bg-accent",
|
||||
)}
|
||||
key={c.id}
|
||||
@@ -544,7 +544,7 @@ export function MessagesView() {
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="flex min-w-0 flex-1 items-center gap-3 rounded-lg py-2 pr-1 text-left"
|
||||
className="flex min-w-0 flex-1 items-center gap-3 rounded-lg py-2 pe-1 text-start"
|
||||
onClick={() => open(c.id)}
|
||||
type="button"
|
||||
>
|
||||
@@ -880,10 +880,10 @@ export function MessagesView() {
|
||||
</DialogHeader>
|
||||
<DialogPanel className="flex flex-col gap-2">
|
||||
<div className="relative">
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 start-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label={t("messages.compose.searchPlaceholder")}
|
||||
className="pl-9"
|
||||
className="ps-9"
|
||||
onChange={(e) => setMemberQuery(e.target.value)}
|
||||
placeholder={t("messages.compose.searchPlaceholder")}
|
||||
size="sm"
|
||||
@@ -902,7 +902,7 @@ export function MessagesView() {
|
||||
) : (
|
||||
visibleMembers.map((m) => (
|
||||
<button
|
||||
className="flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left transition-colors hover:bg-accent"
|
||||
className="flex w-full items-center gap-3 rounded-lg px-2 py-2 text-start transition-colors hover:bg-accent"
|
||||
key={m.id}
|
||||
onClick={() => startConversation(m.id)}
|
||||
type="button"
|
||||
@@ -932,10 +932,10 @@ export function MessagesView() {
|
||||
</DialogHeader>
|
||||
<DialogPanel className="flex flex-col gap-2">
|
||||
<div className="relative">
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 start-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label={t("messages.attach.apptSearchPlaceholder")}
|
||||
className="pl-9"
|
||||
className="ps-9"
|
||||
onChange={(e) => setApptQuery(e.target.value)}
|
||||
placeholder={t("messages.attach.apptSearchPlaceholder")}
|
||||
size="sm"
|
||||
@@ -954,7 +954,7 @@ export function MessagesView() {
|
||||
) : (
|
||||
visibleAppts.map((a) => (
|
||||
<button
|
||||
className="flex w-full flex-col gap-0.5 rounded-lg px-2 py-2 text-left transition-colors hover:bg-accent"
|
||||
className="flex w-full flex-col gap-0.5 rounded-lg px-2 py-2 text-start transition-colors hover:bg-accent"
|
||||
key={a.id}
|
||||
onClick={() => attachAppointment(a)}
|
||||
type="button"
|
||||
|
||||
@@ -153,7 +153,7 @@ export function NotesView() {
|
||||
<div className="divide-y divide-border overflow-hidden rounded-2xl border bg-card/30">
|
||||
{notes.map((n) => (
|
||||
<button
|
||||
className="flex w-full flex-col items-start gap-0.5 px-4 py-3 text-left transition-colors hover:bg-accent/50"
|
||||
className="flex w-full flex-col items-start gap-0.5 px-4 py-3 text-start transition-colors hover:bg-accent/50"
|
||||
key={n.id}
|
||||
onClick={() => openNote(n)}
|
||||
type="button"
|
||||
|
||||
@@ -296,7 +296,7 @@ export function PatientDetail({
|
||||
{onDelete && (
|
||||
<Button
|
||||
aria-label={t("patients.delete.action")}
|
||||
className="ml-auto"
|
||||
className="ms-auto"
|
||||
onClick={onDelete}
|
||||
size="sm"
|
||||
type="button"
|
||||
@@ -358,7 +358,7 @@ export function PatientDetail({
|
||||
<div className="divide-y divide-border overflow-hidden rounded-xl border bg-card/30">
|
||||
{files.map((file) => (
|
||||
<button
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2 text-left transition-colors hover:bg-accent"
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2 text-start transition-colors hover:bg-accent"
|
||||
key={file.id}
|
||||
onClick={() => setOpenFile(file)}
|
||||
type="button"
|
||||
|
||||
@@ -210,7 +210,7 @@ export function AttachmentsSection({
|
||||
key={attachment.id}
|
||||
>
|
||||
<button
|
||||
className="flex min-w-0 flex-1 items-center gap-2.5 text-left"
|
||||
className="flex min-w-0 flex-1 items-center gap-2.5 text-start"
|
||||
onClick={() => setPreview(attachment)}
|
||||
type="button"
|
||||
>
|
||||
|
||||
@@ -115,9 +115,9 @@ export function PatientsView() {
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 start-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="w-full pl-9 sm:w-64"
|
||||
className="w-full ps-9 sm:w-64"
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
setPage(1);
|
||||
@@ -159,7 +159,7 @@ export function PatientsView() {
|
||||
<div className="mt-8 overflow-hidden rounded-2xl border border-border bg-card/30">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-border border-b text-left text-xs text-muted-foreground uppercase">
|
||||
<tr className="border-border border-b text-start text-xs text-muted-foreground uppercase">
|
||||
<th className="px-4 py-3 font-medium">{t("patients.columns.name")}</th>
|
||||
<th className="px-4 py-3 font-medium">{t("patients.columns.mrn")}</th>
|
||||
<th className="px-4 py-3 font-medium">
|
||||
|
||||
@@ -35,7 +35,7 @@ function Row({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 py-2">
|
||||
<span className="text-muted-foreground text-sm">{label}</span>
|
||||
<span className="text-right text-foreground text-sm">{value}</span>
|
||||
<span className="text-end text-foreground text-sm">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -68,7 +68,7 @@ export function InventoryDetailDialog({
|
||||
</span>
|
||||
<span className="min-w-0 truncate">{item.name}</span>
|
||||
<Badge
|
||||
className="ml-auto shrink-0"
|
||||
className="ms-auto shrink-0"
|
||||
variant={availabilityVariant[availability]}
|
||||
>
|
||||
{t(`inventory.availability.${availability}`)}
|
||||
|
||||
@@ -67,7 +67,7 @@ function ItemRow({
|
||||
const descriptor = [item.strength, item.form].filter(Boolean).join(" · ");
|
||||
return (
|
||||
<div
|
||||
className="flex w-full cursor-pointer items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-accent/50"
|
||||
className="flex w-full cursor-pointer items-center gap-3 px-4 py-3 text-start transition-colors hover:bg-accent/50"
|
||||
onClick={onOpen}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
@@ -205,9 +205,9 @@ export function InventoryView() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 start-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="w-full pl-9 sm:w-64"
|
||||
className="w-full ps-9 sm:w-64"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t("inventory.searchPlaceholder")}
|
||||
value={query}
|
||||
|
||||
@@ -330,9 +330,9 @@ export function PharmacyView() {
|
||||
<p className="text-muted-foreground text-sm">{t("pharmacy.subtitle")}</p>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 start-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="w-full pl-9 sm:w-64"
|
||||
className="w-full ps-9 sm:w-64"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t("pharmacy.searchPlaceholder")}
|
||||
value={query}
|
||||
|
||||
@@ -43,7 +43,7 @@ function Field({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex flex-col gap-1.5 text-left">
|
||||
<label className="flex flex-col gap-1.5 text-start">
|
||||
<span className="font-medium text-foreground text-sm">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
@@ -122,7 +122,7 @@ function ChooseStep({ onPick }: { onPick: (step: Step) => void }) {
|
||||
<div className="grid w-full gap-4 sm:grid-cols-2">
|
||||
{cards.map((c) => (
|
||||
<button
|
||||
className="group flex flex-col items-start gap-4 rounded-3xl border bg-card/40 p-6 text-left transition-colors hover:border-primary/50 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="group flex flex-col items-start gap-4 rounded-3xl border bg-card/40 p-6 text-start transition-colors hover:border-primary/50 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
key={c.step}
|
||||
onClick={() => onPick(c.step)}
|
||||
type="button"
|
||||
@@ -133,7 +133,7 @@ function ChooseStep({ onPick }: { onPick: (step: Step) => void }) {
|
||||
<span className="flex flex-col gap-1">
|
||||
<span className="flex items-center gap-1 font-semibold text-lg text-foreground">
|
||||
{c.title}
|
||||
<ChevronRight className="size-4 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
|
||||
<ChevronRight className="size-4 text-muted-foreground transition-transform group-hover:translate-x-0.5 rtl:rotate-180" />
|
||||
</span>
|
||||
<span className="text-muted-foreground text-sm">{c.desc}</span>
|
||||
</span>
|
||||
@@ -151,7 +151,7 @@ function BackButton({ onBack }: { onBack: () => void }) {
|
||||
onClick={onBack}
|
||||
type="button"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
<ArrowLeft className="size-4 rtl:rotate-180" />
|
||||
{t("portal.back")}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -378,11 +378,11 @@ export function AddPrescriptionDialog({
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="relative">
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 start-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
aria-autocomplete="list"
|
||||
autoFocus
|
||||
className="pl-9"
|
||||
className="ps-9"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={onPatientKeyDown}
|
||||
placeholder={t("prescriptions.dialog.searchPlaceholder")}
|
||||
@@ -399,7 +399,7 @@ export function AddPrescriptionDialog({
|
||||
matches.map((p, i) => (
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-left transition-colors",
|
||||
"flex w-full items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-start transition-colors",
|
||||
i === activeIndex
|
||||
? "bg-accent"
|
||||
: "hover:bg-accent",
|
||||
@@ -442,7 +442,7 @@ export function AddPrescriptionDialog({
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-left transition-colors",
|
||||
"flex w-full items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-start transition-colors",
|
||||
i === medIndex ? "bg-accent" : "hover:bg-accent",
|
||||
)}
|
||||
// Use onMouseDown so the pick fires before the input's
|
||||
|
||||
@@ -147,7 +147,7 @@ export function PrescriptionDetailSheet({
|
||||
<SheetFooter>
|
||||
{onDelete && (
|
||||
<Button
|
||||
className="sm:mr-auto"
|
||||
className="sm:me-auto"
|
||||
onClick={onDelete}
|
||||
type="button"
|
||||
variant="destructive"
|
||||
|
||||
@@ -244,9 +244,9 @@ export function PrescriptionsView() {
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div className="relative">
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 start-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="w-full pl-9 sm:w-64"
|
||||
className="w-full ps-9 sm:w-64"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t("prescriptions.searchPlaceholder")}
|
||||
value={query}
|
||||
|
||||
@@ -422,7 +422,7 @@ export function EmployeeDetailDialog({
|
||||
<DialogFooter>
|
||||
{editable && member && (
|
||||
<Button
|
||||
className="sm:mr-auto"
|
||||
className="sm:me-auto"
|
||||
onClick={() => onRemove(member)}
|
||||
type="button"
|
||||
variant="destructive"
|
||||
|
||||
@@ -109,7 +109,7 @@ export function SigningPanel() {
|
||||
: t("settings.signing.rotateKey")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="sm:text-right">
|
||||
<div className="sm:text-end">
|
||||
<p className="text-3xl font-semibold tracking-tight">Ed25519</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{key
|
||||
|
||||
@@ -164,11 +164,11 @@ export function CareTeamPanel({
|
||||
{initials(m.name, m.email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<div className="min-w-0 flex-1 text-start">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{m.name || m.email || m.userId}
|
||||
{isSelf && (
|
||||
<span className="ml-1 text-xs text-muted-foreground">
|
||||
<span className="ms-1 text-xs text-muted-foreground">
|
||||
{t("settings.careTeam.you")}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -179,7 +179,7 @@ function IntegrationCard({
|
||||
: t("settings.integrations.test")}
|
||||
</Button>
|
||||
{config.lastSyncAt ? (
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
<span className="ms-auto text-xs text-muted-foreground">
|
||||
{t("settings.integrations.lastSync", {
|
||||
when: new Date(config.lastSyncAt).toLocaleString(),
|
||||
})}
|
||||
|
||||
@@ -108,7 +108,7 @@ export function CopyField({
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex h-9 w-full items-center gap-2 rounded-3xl bg-input/50 pr-1 pl-3 sm:w-80">
|
||||
<div className="flex h-9 w-full items-center gap-2 rounded-3xl bg-input/50 pe-1 ps-3 sm:w-80">
|
||||
<span className="flex-1 truncate text-sm text-muted-foreground">
|
||||
{value}
|
||||
</span>
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from "@/components/settings/settings-parts";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { supportedLanguages } from "@/lib/i18n/config";
|
||||
import { persistLanguage } from "@/lib/language";
|
||||
import {
|
||||
getSettings,
|
||||
saveSettings,
|
||||
@@ -341,7 +342,10 @@ export function ProfilePanel() {
|
||||
: undefined
|
||||
}
|
||||
onConfirm={() => {
|
||||
if (pendingLang) void i18n.changeLanguage(pendingLang);
|
||||
if (pendingLang) {
|
||||
void i18n.changeLanguage(pendingLang);
|
||||
void persistLanguage(pendingLang);
|
||||
}
|
||||
}}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPendingLang(null);
|
||||
|
||||
@@ -21,7 +21,7 @@ export function MobileSidebarTrigger() {
|
||||
return (
|
||||
<Button
|
||||
aria-label={t("nav.openSidebar")}
|
||||
className="fixed top-3 right-3 z-50 size-9 rounded-full bg-background/80 shadow-sm backdrop-blur md:hidden"
|
||||
className="fixed top-3 end-3 z-50 size-9 rounded-full bg-background/80 shadow-sm backdrop-blur md:hidden"
|
||||
onClick={toggleSidebar}
|
||||
size="icon"
|
||||
variant="outline"
|
||||
|
||||
@@ -66,7 +66,7 @@ export default function DashboardNavigation({ routes }: { routes: Route[] }) {
|
||||
>
|
||||
{route.icon}
|
||||
{!isCollapsed && (
|
||||
<span className="ml-2 flex-1 truncate font-medium text-sm">
|
||||
<span className="ms-2 flex-1 truncate font-medium text-sm">
|
||||
{route.title}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -53,7 +53,7 @@ export function NotificationsPopover() {
|
||||
>
|
||||
<BellIcon className="size-5" />
|
||||
{unread > 0 && (
|
||||
<span className="-top-0.5 -right-0.5 absolute flex min-w-4 items-center justify-center rounded-full bg-primary px-1 font-medium text-[10px] text-primary-foreground leading-4">
|
||||
<span className="-top-0.5 -end-0.5 absolute flex min-w-4 items-center justify-center rounded-full bg-primary px-1 font-medium text-[10px] text-primary-foreground leading-4">
|
||||
{unread > 9 ? "9+" : unread}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -146,13 +146,13 @@ export function NavUser() {
|
||||
</Avatar>
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<div className="grid flex-1 text-start text-sm leading-tight">
|
||||
<span className="truncate font-medium">{name}</span>
|
||||
<span className="truncate text-muted-foreground text-xs">
|
||||
{secondary}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-auto size-4" />
|
||||
<ChevronsUpDown className="ms-auto size-4" />
|
||||
</>
|
||||
)}
|
||||
</MenuTrigger>
|
||||
@@ -167,7 +167,7 @@ export function NavUser() {
|
||||
<Avatar className="size-8">
|
||||
<AvatarFallback>{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<div className="grid flex-1 text-start text-sm leading-tight">
|
||||
<span className="truncate font-medium">{name}</span>
|
||||
<span className="truncate text-muted-foreground text-xs">
|
||||
{secondary}
|
||||
|
||||
@@ -33,7 +33,7 @@ export function AccordionTrigger({
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
className={cn(
|
||||
"flex flex-1 cursor-pointer items-start justify-between gap-4 rounded-md py-4 text-left font-medium text-sm outline-none transition-all focus-visible:ring-[3px] focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-64 data-panel-open:*:data-[slot=accordion-indicator]:rotate-180",
|
||||
"flex flex-1 cursor-pointer items-start justify-between gap-4 rounded-md py-4 text-start font-medium text-sm outline-none transition-all focus-visible:ring-[3px] focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-64 data-panel-open:*:data-[slot=accordion-indicator]:rotate-180",
|
||||
className,
|
||||
)}
|
||||
data-slot="accordion-trigger"
|
||||
|
||||
@@ -94,8 +94,8 @@ const bubbleReactionsVariants = cva(
|
||||
bottom: "bottom-0 translate-y-3/4",
|
||||
},
|
||||
align: {
|
||||
start: "left-3",
|
||||
end: "right-3",
|
||||
start: "start-3",
|
||||
end: "end-3",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -76,7 +76,7 @@ export function ListPagination({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<ChevronLeft className="size-4 rtl:rotate-180" />
|
||||
<span className="max-sm:hidden">
|
||||
{t("common.pagination.previous")}
|
||||
</span>
|
||||
@@ -115,7 +115,7 @@ export function ListPagination({
|
||||
<span className="max-sm:hidden">
|
||||
{t("common.pagination.next")}
|
||||
</span>
|
||||
<ChevronRight className="size-4" />
|
||||
<ChevronRight className="size-4 rtl:rotate-180" />
|
||||
</Button>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
|
||||
@@ -275,7 +275,7 @@ export function MenuSubTrigger({
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ms-auto -me-0.5 opacity-80" />
|
||||
<ChevronRightIcon className="ms-auto -me-0.5 opacity-80 rtl:rotate-180" />
|
||||
</MenuPrimitive.SubmenuTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ export function PaginationPrevious({
|
||||
size="default"
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeftIcon className="sm:-ms-1" />
|
||||
<ChevronLeftIcon className="sm:-ms-1 rtl:rotate-180" />
|
||||
<span className="max-sm:hidden">Previous</span>
|
||||
</PaginationLink>
|
||||
);
|
||||
@@ -107,7 +107,7 @@ export function PaginationNext({
|
||||
{...props}
|
||||
>
|
||||
<span className="max-sm:hidden">Next</span>
|
||||
<ChevronRightIcon className="sm:-me-1" />
|
||||
<ChevronRightIcon className="sm:-me-1 rtl:rotate-180" />
|
||||
</PaginationLink>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { cn } from "@/lib/utils";
|
||||
export const Select: typeof SelectPrimitive.Root = SelectPrimitive.Root;
|
||||
|
||||
export const selectTriggerVariants = cva(
|
||||
"relative inline-flex min-h-9 w-full min-w-36 select-none items-center justify-between gap-2 rounded-lg border border-input bg-background not-dark:bg-clip-padding px-[calc(--spacing(3)-1px)] text-left text-base text-foreground shadow-xs/5 outline-none ring-ring/24 transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] not-data-disabled:not-focus-visible:not-aria-invalid:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 focus-visible:border-ring focus-visible:ring-[3px] aria-invalid:border-destructive/36 focus-visible:aria-invalid:border-destructive/64 focus-visible:aria-invalid:ring-destructive/16 data-disabled:pointer-events-none data-disabled:opacity-64 sm:min-h-8 sm:text-sm dark:bg-input/32 dark:aria-invalid:ring-destructive/24 dark:not-data-disabled:not-focus-visible:not-aria-invalid:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [&_svg:not([class*='opacity-'])]:opacity-80 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0 [[data-disabled],:focus-visible,[aria-invalid],[data-pressed]]:shadow-none",
|
||||
"relative inline-flex min-h-9 w-full min-w-36 select-none items-center justify-between gap-2 rounded-lg border border-input bg-background not-dark:bg-clip-padding px-[calc(--spacing(3)-1px)] text-start text-base text-foreground shadow-xs/5 outline-none ring-ring/24 transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] not-data-disabled:not-focus-visible:not-aria-invalid:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 focus-visible:border-ring focus-visible:ring-[3px] aria-invalid:border-destructive/36 focus-visible:aria-invalid:border-destructive/64 focus-visible:aria-invalid:ring-destructive/16 data-disabled:pointer-events-none data-disabled:opacity-64 sm:min-h-8 sm:text-sm dark:bg-input/32 dark:aria-invalid:ring-destructive/24 dark:not-data-disabled:not-focus-visible:not-aria-invalid:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [&_svg:not([class*='opacity-'])]:opacity-80 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0 [[data-disabled],:focus-visible,[aria-invalid],[data-pressed]]:shadow-none",
|
||||
{
|
||||
defaultVariants: {
|
||||
size: "default",
|
||||
|
||||
@@ -33,7 +33,7 @@ const SIDEBAR_WIDTH_ICON: string = "3rem";
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT: string = "b";
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-lg p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pe-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0",
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-lg p-2 text-start text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pe-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
defaultVariants: {
|
||||
size: "default",
|
||||
|
||||
@@ -40,7 +40,7 @@ export function UpdateBanner() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed right-4 bottom-4 z-50 flex max-w-sm items-start gap-3 rounded-2xl border border-border bg-card px-4 py-3 shadow-lg">
|
||||
<div className="fixed end-4 bottom-4 z-50 flex max-w-sm items-start gap-3 rounded-2xl border border-border bg-card px-4 py-3 shadow-lg">
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-sm text-foreground">
|
||||
{t("settings.version.banner", { version: latest })}
|
||||
@@ -55,7 +55,7 @@ export function UpdateBanner() {
|
||||
</div>
|
||||
<button
|
||||
aria-label={t("settings.version.bannerDismiss")}
|
||||
className="-mr-1 shrink-0 rounded-lg p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
className="-me-1 shrink-0 rounded-lg p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={dismiss}
|
||||
type="button"
|
||||
>
|
||||
|
||||
@@ -6,6 +6,9 @@ import { initReactI18next } from "react-i18next";
|
||||
|
||||
import en from "./locales/en/translation.json";
|
||||
import fr from "./locales/fr/translation.json";
|
||||
import so from "./locales/so/translation.json";
|
||||
import ar from "./locales/ar/translation.json";
|
||||
import de from "./locales/de/translation.json";
|
||||
|
||||
export const defaultNS = "translation";
|
||||
|
||||
@@ -13,10 +16,21 @@ export const defaultNS = "translation";
|
||||
export const resources = {
|
||||
en: { translation: en },
|
||||
fr: { translation: fr },
|
||||
so: { translation: so },
|
||||
ar: { translation: ar },
|
||||
de: { translation: de },
|
||||
} as const;
|
||||
|
||||
// Languages offered in the Settings → Profile switcher (label rendered there).
|
||||
export const supportedLanguages = ["en", "fr"] as const;
|
||||
export const supportedLanguages = ["en", "fr", "so", "ar", "de"] as const;
|
||||
|
||||
// Right-to-left languages. Arabic is our only RTL locale today; keep this and the
|
||||
// inline <head> script in app/layout.tsx (which can't import this module) in sync.
|
||||
export const rtlLanguages = ["ar"] as const;
|
||||
|
||||
/** Writing direction for a BCP-47 language tag (e.g. "ar", "ar-SA"). */
|
||||
export const dirFor = (lng: string | undefined): "rtl" | "ltr" =>
|
||||
lng && rtlLanguages.some((r) => lng.startsWith(r)) ? "rtl" : "ltr";
|
||||
|
||||
if (!i18n.isInitialized) {
|
||||
i18n
|
||||
@@ -27,7 +41,7 @@ if (!i18n.isInitialized) {
|
||||
defaultNS,
|
||||
fallbackLng: "en",
|
||||
// Keep this in sync with `resources` as languages grow.
|
||||
supportedLngs: ["en", "fr"],
|
||||
supportedLngs: ["en", "fr", "so", "ar", "de"],
|
||||
interpolation: { escapeValue: false },
|
||||
detection: {
|
||||
order: ["localStorage", "navigator", "htmlTag"],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1690,6 +1690,9 @@
|
||||
"label": "Display language",
|
||||
"en": "English",
|
||||
"fr": "Français",
|
||||
"so": "Soomaali",
|
||||
"ar": "العربية",
|
||||
"de": "Deutsch",
|
||||
"confirmTitle": "Change display language?",
|
||||
"confirmBody": "Switch the interface to {{language}}? The app will reload its text in the new language.",
|
||||
"confirmCta": "Change language",
|
||||
|
||||
@@ -1690,6 +1690,9 @@
|
||||
"label": "Langue d'affichage",
|
||||
"en": "English",
|
||||
"fr": "Français",
|
||||
"so": "Soomaali",
|
||||
"ar": "العربية",
|
||||
"de": "Deutsch",
|
||||
"confirmTitle": "Changer la langue d'affichage ?",
|
||||
"confirmBody": "Basculer l'interface en {{language}} ? L'application rechargera ses textes dans la nouvelle langue.",
|
||||
"confirmCta": "Changer la langue",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
import i18n, { supportedLanguages } from "@/lib/i18n/config";
|
||||
import { getSettings, saveSettings } from "@/lib/settings";
|
||||
|
||||
// The user's chosen UI language roams across devices via the backend
|
||||
// `user_settings` preferences map (localStorage stays the offline source of
|
||||
// truth). We store it under this key alongside notification preferences.
|
||||
const LANG_KEY = "language";
|
||||
|
||||
const isSupported = (value: unknown): value is string =>
|
||||
typeof value === "string" &&
|
||||
(supportedLanguages as readonly string[]).includes(value);
|
||||
|
||||
/**
|
||||
* Persist the chosen language to the backend, merging into existing preferences
|
||||
* so notification toggles aren't clobbered (PUT replaces the whole map).
|
||||
* Best-effort: failures are swallowed since localStorage already holds the choice.
|
||||
*/
|
||||
export async function persistLanguage(lang: string): Promise<void> {
|
||||
try {
|
||||
const current = await getSettings();
|
||||
if (current[LANG_KEY] === lang) return;
|
||||
await saveSettings({ ...current, [LANG_KEY]: lang });
|
||||
} catch {
|
||||
// Ignore — the language is already applied and cached in localStorage.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On app load, adopt the language saved on the backend if it differs from the
|
||||
* locally detected one (roaming to a new device). No-op when offline or when the
|
||||
* stored value matches; localStorage remains authoritative otherwise.
|
||||
*/
|
||||
export async function applyStoredLanguage(): Promise<void> {
|
||||
try {
|
||||
const current = await getSettings();
|
||||
const lang = current[LANG_KEY];
|
||||
if (isSupported(lang) && lang !== (i18n.resolvedLanguage ?? i18n.language)) {
|
||||
await i18n.changeLanguage(lang);
|
||||
}
|
||||
} catch {
|
||||
// Ignore — unauthenticated or offline; keep the detected language.
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.2.5",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"lint": "eslint",
|
||||
"check-locales": "node scripts/check-locales.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^3.0.206",
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env node
|
||||
// Locale parity check: every locale under lib/i18n/locales must have the same set
|
||||
// of logical keys as English, with matching {{interpolation}} placeholders.
|
||||
//
|
||||
// "Logical key" collapses i18next plural suffixes: `foo`, `foo_one`, `foo_other`,
|
||||
// `foo_zero`, `foo_two`, `foo_few`, `foo_many` all map to the logical key `foo`.
|
||||
// This lets Arabic carry six CLDR plural forms where English has two, without the
|
||||
// check flagging a mismatch.
|
||||
//
|
||||
// Exit code is non-zero if any locale is missing/extra keys or has a placeholder
|
||||
// mismatch. Arabic count-keys missing the full six CLDR forms are warnings only.
|
||||
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const localesDir = join(here, "..", "lib", "i18n", "locales");
|
||||
const REFERENCE = "en";
|
||||
const PLURAL_SUFFIXES = ["zero", "one", "two", "few", "many", "other"];
|
||||
const AR_REQUIRED = ["zero", "one", "two", "few", "many", "other"];
|
||||
|
||||
/** Flatten a nested object into dotted paths → string values. */
|
||||
function flatten(obj, prefix = "", out = {}) {
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const path = prefix ? `${prefix}.${key}` : key;
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
flatten(value, path, out);
|
||||
} else {
|
||||
out[path] = value;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Strip a trailing i18next plural suffix, returning [logicalKey, suffix|null]. */
|
||||
function splitPlural(path) {
|
||||
for (const suffix of PLURAL_SUFFIXES) {
|
||||
if (path.endsWith(`_${suffix}`)) {
|
||||
return [path.slice(0, -(suffix.length + 1)), suffix];
|
||||
}
|
||||
}
|
||||
return [path, null];
|
||||
}
|
||||
|
||||
/** All {{placeholder}} names in a string, sorted and de-duplicated. */
|
||||
function placeholders(value) {
|
||||
if (typeof value !== "string") return [];
|
||||
const found = new Set();
|
||||
for (const match of value.matchAll(/\{\{\s*([\w.-]+)\s*\}\}/g)) {
|
||||
found.add(match[1]);
|
||||
}
|
||||
return [...found].sort();
|
||||
}
|
||||
|
||||
function logicalKeys(flat) {
|
||||
// logicalKey → { suffixes: Set, placeholders: string[] }
|
||||
const map = new Map();
|
||||
for (const [path, value] of Object.entries(flat)) {
|
||||
const [logical, suffix] = splitPlural(path);
|
||||
const entry = map.get(logical) ?? { suffixes: new Set(), placeholders: [] };
|
||||
if (suffix) entry.suffixes.add(suffix);
|
||||
// Placeholders should be identical across plural forms; take the richest set.
|
||||
const ph = placeholders(value);
|
||||
if (ph.length > entry.placeholders.length) entry.placeholders = ph;
|
||||
map.set(logical, entry);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
const load = (lng) =>
|
||||
JSON.parse(readFileSync(join(localesDir, lng, "translation.json"), "utf8"));
|
||||
|
||||
const reference = logicalKeys(flatten(load(REFERENCE)));
|
||||
const locales = readdirSync(localesDir, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory() && d.name !== REFERENCE)
|
||||
.map((d) => d.name);
|
||||
|
||||
let hadError = false;
|
||||
|
||||
for (const lng of locales) {
|
||||
const flat = flatten(load(lng));
|
||||
const keys = logicalKeys(flat);
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
|
||||
for (const [key, ref] of reference) {
|
||||
if (!keys.has(key)) {
|
||||
errors.push(`missing key: ${key}`);
|
||||
continue;
|
||||
}
|
||||
const got = keys.get(key);
|
||||
const refPh = ref.placeholders.join(",");
|
||||
const gotPh = got.placeholders.join(",");
|
||||
if (refPh !== gotPh) {
|
||||
errors.push(`placeholder mismatch at ${key}: en[${refPh}] vs ${lng}[${gotPh}]`);
|
||||
}
|
||||
if (lng === "ar" && ref.suffixes.size > 0) {
|
||||
const missing = AR_REQUIRED.filter((s) => !got.suffixes.has(s));
|
||||
if (missing.length) {
|
||||
warnings.push(`ar plural ${key} missing CLDR forms: ${missing.join(", ")}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const key of keys.keys()) {
|
||||
if (!reference.has(key)) errors.push(`extra key not in en: ${key}`);
|
||||
}
|
||||
|
||||
if (errors.length) {
|
||||
hadError = true;
|
||||
console.error(`\n✖ ${lng}: ${errors.length} error(s)`);
|
||||
for (const e of errors) console.error(` - ${e}`);
|
||||
} else {
|
||||
console.log(`✔ ${lng}: parity OK (${reference.size} keys)`);
|
||||
}
|
||||
for (const w of warnings) console.warn(` ⚠ ${w}`);
|
||||
}
|
||||
|
||||
process.exit(hadError ? 1 : 0);
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "temetro",
|
||||
"version": "0.2.5",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"shadcn": "^4.11.0"
|
||||
|
||||
Reference in New Issue
Block a user