mirror of
https://github.com/temetro/temetro.git
synced 2026-08-07 17:23:14 +00:00
d237504af9
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>
92 lines
2.8 KiB
TypeScript
92 lines
2.8 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
|
|
import { Sparkline } from "@/components/chat/sparkline";
|
|
import { Card } from "@/components/ui/card";
|
|
import {
|
|
Dialog,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogPanel,
|
|
DialogPopup,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import type { TrendPoint } from "@/lib/analytics";
|
|
|
|
// A KPI card with an inline line chart (Sparkline). Clicking it opens a dialog
|
|
// with the full line chart and a per-point breakdown. Used on the Analysis page.
|
|
export function TrendCard({
|
|
title,
|
|
description,
|
|
points,
|
|
emptyLabel,
|
|
detailsLabel,
|
|
}: {
|
|
title: string;
|
|
description: string;
|
|
points: TrendPoint[];
|
|
emptyLabel: string;
|
|
detailsLabel: string;
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
const values = points.map((p) => p.count);
|
|
const total = values.reduce((a, b) => a + b, 0);
|
|
const hasData = points.length > 0;
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
className="w-full text-start"
|
|
disabled={!hasData}
|
|
onClick={() => setOpen(true)}
|
|
type="button"
|
|
>
|
|
<Card className="gap-3 p-4 transition-colors hover:border-ring/40 hover:bg-accent/30">
|
|
<div className="flex items-baseline justify-between gap-2">
|
|
<span className="text-muted-foreground text-sm">{title}</span>
|
|
<span className="font-semibold text-foreground text-xl tabular-nums">
|
|
{total}
|
|
</span>
|
|
</div>
|
|
{hasData ? (
|
|
<Sparkline className="h-12" points={values} />
|
|
) : (
|
|
<p className="py-4 text-center text-muted-foreground text-xs">
|
|
{emptyLabel}
|
|
</p>
|
|
)}
|
|
{hasData && (
|
|
<span className="text-muted-foreground text-xs">{detailsLabel}</span>
|
|
)}
|
|
</Card>
|
|
</button>
|
|
|
|
<Dialog onOpenChange={setOpen} open={open}>
|
|
<DialogPopup className="sm:max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle>{title}</DialogTitle>
|
|
<DialogDescription>{description}</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogPanel className="flex flex-col gap-5">
|
|
<Sparkline className="h-36" points={values} />
|
|
<dl className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm sm:grid-cols-3">
|
|
{points.map((p) => (
|
|
<div
|
|
className="flex items-center justify-between border-border/60 border-b py-1"
|
|
key={p.label}
|
|
>
|
|
<dt className="text-muted-foreground">{p.label}</dt>
|
|
<dd className="font-medium text-foreground tabular-nums">
|
|
{p.count}
|
|
</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
</DialogPanel>
|
|
</DialogPopup>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|