mirror of
https://github.com/temetro/temetro.git
synced 2026-09-04 14:55:28 +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>
101 lines
3.1 KiB
TypeScript
101 lines
3.1 KiB
TypeScript
"use client";
|
|
|
|
import { BellIcon } from "lucide-react";
|
|
import { useRouter } from "next/navigation";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Menu,
|
|
MenuGroup,
|
|
MenuGroupLabel,
|
|
MenuItem,
|
|
MenuPopup,
|
|
MenuSeparator,
|
|
MenuTrigger,
|
|
} from "@/components/ui/menu";
|
|
import { markNotificationRead, notificationHref } from "@/lib/notifications";
|
|
import { useNotifications } from "@/lib/use-notifications";
|
|
|
|
// ISO timestamp -> "just now" / "10m ago" / "3h ago" / "2d ago".
|
|
function relativeTime(iso: string): string {
|
|
const diff = Date.now() - new Date(iso).getTime();
|
|
const mins = Math.floor(diff / 60000);
|
|
if (mins < 1) return "just now";
|
|
if (mins < 60) return `${mins}m ago`;
|
|
const hours = Math.floor(mins / 60);
|
|
if (hours < 24) return `${hours}h ago`;
|
|
return `${Math.floor(hours / 24)}d ago`;
|
|
}
|
|
|
|
export function NotificationsPopover() {
|
|
const { t } = useTranslation();
|
|
const router = useRouter();
|
|
const { items, unread, markAllRead } = useNotifications();
|
|
|
|
return (
|
|
<Menu
|
|
onOpenChange={(open) => {
|
|
// Viewing the list clears the unread badge.
|
|
if (open && unread > 0) markAllRead();
|
|
}}
|
|
>
|
|
<MenuTrigger
|
|
render={
|
|
<Button
|
|
aria-label={t("nav.notifications")}
|
|
className="relative rounded-full"
|
|
size="icon"
|
|
variant="ghost"
|
|
/>
|
|
}
|
|
>
|
|
<BellIcon className="size-5" />
|
|
{unread > 0 && (
|
|
<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>
|
|
)}
|
|
</MenuTrigger>
|
|
<MenuPopup side="right" className="my-6 w-80">
|
|
<MenuGroup>
|
|
<MenuGroupLabel>{t("nav.notifications")}</MenuGroupLabel>
|
|
</MenuGroup>
|
|
<MenuSeparator />
|
|
{items.length === 0 ? (
|
|
<div className="px-3 py-6 text-center text-muted-foreground text-sm">
|
|
{t("nav.notificationsEmpty")}
|
|
</div>
|
|
) : (
|
|
items.map((n) => {
|
|
const href = notificationHref(n);
|
|
return (
|
|
<MenuItem
|
|
className="flex items-start gap-3"
|
|
disabled={!href}
|
|
key={n.id}
|
|
onClick={() => {
|
|
if (!href) return;
|
|
if (!n.read) void markNotificationRead(n.id).catch(() => {});
|
|
router.push(href);
|
|
}}
|
|
>
|
|
<Avatar className="size-8">
|
|
<AvatarFallback>{n.actorInitials ?? "•"}</AvatarFallback>
|
|
</Avatar>
|
|
<div className="flex min-w-0 flex-col">
|
|
<span className="font-medium text-sm">{n.text}</span>
|
|
<span className="text-muted-foreground text-xs">
|
|
{relativeTime(n.createdAt)}
|
|
</span>
|
|
</div>
|
|
</MenuItem>
|
|
);
|
|
})
|
|
)}
|
|
</MenuPopup>
|
|
</Menu>
|
|
);
|
|
}
|