mirror of
https://github.com/temetro/temetro.git
synced 2026-08-19 14:46:29 +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>
507 lines
18 KiB
TypeScript
507 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import { CalendarDays, Plus, Trash2, Users, Video } from "lucide-react";
|
|
import { useSearchParams } from "next/navigation";
|
|
import { type FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
import { MeetingRoom } from "@/components/meetings/meeting-room";
|
|
import { ScheduleMeetingDialog } from "@/components/meetings/schedule-meeting-dialog";
|
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Calendar } from "@/components/ui/calendar";
|
|
import {
|
|
Dialog,
|
|
DialogClose,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogPanel,
|
|
DialogPopup,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
Empty,
|
|
EmptyContent,
|
|
EmptyDescription,
|
|
EmptyHeader,
|
|
EmptyMedia,
|
|
EmptyTitle,
|
|
} from "@/components/ui/empty";
|
|
import { Input } from "@/components/ui/input";
|
|
import { authClient } from "@/lib/auth-client";
|
|
import {
|
|
createMeetingRoom,
|
|
deleteMeetingRoom,
|
|
listMeetingEvents,
|
|
listMeetingRooms,
|
|
type MeetingRoom as Room,
|
|
type ScheduledMeeting,
|
|
} from "@/lib/meetings";
|
|
import { getSocket } from "@/lib/socket";
|
|
import { notify } from "@/lib/toast";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
type Tab = "rooms" | "calendar";
|
|
|
|
// Local YYYY-MM-DD key for a Date (no UTC drift), matching how meetings store date.
|
|
function keyOf(date: Date): string {
|
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
|
}
|
|
|
|
export function MeetingsView() {
|
|
const { t } = useTranslation();
|
|
const { data: session } = authClient.useSession();
|
|
const selfName = session?.user?.name ?? "";
|
|
|
|
const searchParams = useSearchParams();
|
|
const deepLinkRoom = searchParams.get("room");
|
|
// ?with=<userId> from the Messages inbox "call" button — open the scheduler
|
|
// pre-targeted at that person so the user can connect with them.
|
|
const deepLinkWith = searchParams.get("with");
|
|
const openedDeepLink = useRef<string | null>(null);
|
|
const openedWith = useRef<string | null>(null);
|
|
|
|
const [tab, setTab] = useState<Tab>("rooms");
|
|
|
|
// Rooms / live calls.
|
|
const [rooms, setRooms] = useState<Room[]>([]);
|
|
const [activeRoom, setActiveRoom] = useState<Room | null>(null);
|
|
const [presence, setPresence] = useState<Record<string, number>>({});
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
const [newName, setNewName] = useState("");
|
|
const [creating, setCreating] = useState(false);
|
|
const [roomToDelete, setRoomToDelete] = useState<Room | null>(null);
|
|
const [deleting, setDeleting] = useState(false);
|
|
|
|
// Scheduled meetings (calendar).
|
|
const [events, setEvents] = useState<ScheduledMeeting[]>([]);
|
|
const [selectedDay, setSelectedDay] = useState<Date>(new Date());
|
|
const [scheduleOpen, setScheduleOpen] = useState(false);
|
|
|
|
useEffect(() => {
|
|
listMeetingRooms()
|
|
.then(setRooms)
|
|
.catch(() => {});
|
|
}, []);
|
|
|
|
const loadEvents = () => {
|
|
listMeetingEvents()
|
|
.then(setEvents)
|
|
.catch(() => {});
|
|
};
|
|
useEffect(loadEvents, []);
|
|
|
|
// Live room occupancy.
|
|
useEffect(() => {
|
|
const socket = getSocket();
|
|
const onPresence = ({ roomId, count }: { roomId: string; count: number }) => {
|
|
setPresence((prev) => ({ ...prev, [roomId]: count }));
|
|
};
|
|
socket.on("call:presence", onPresence);
|
|
return () => {
|
|
socket.off("call:presence", onPresence);
|
|
};
|
|
}, []);
|
|
|
|
// Auto-join a room deep-linked from an invite (?room=).
|
|
useEffect(() => {
|
|
if (!deepLinkRoom || rooms.length === 0) return;
|
|
if (openedDeepLink.current === deepLinkRoom) return;
|
|
const room = rooms.find((r) => r.id === deepLinkRoom);
|
|
if (!room) return;
|
|
openedDeepLink.current = deepLinkRoom;
|
|
setTab("rooms");
|
|
setActiveRoom(room);
|
|
}, [deepLinkRoom, rooms]);
|
|
|
|
// Open the scheduler pre-targeted at a person (?with=) from the inbox.
|
|
useEffect(() => {
|
|
if (!deepLinkWith || openedWith.current === deepLinkWith) return;
|
|
openedWith.current = deepLinkWith;
|
|
setTab("calendar");
|
|
setScheduleOpen(true);
|
|
}, [deepLinkWith]);
|
|
|
|
const createRoom = async (event: FormEvent) => {
|
|
event.preventDefault();
|
|
const name = newName.trim();
|
|
if (!name || creating) return;
|
|
setCreating(true);
|
|
try {
|
|
const room = await createMeetingRoom(name);
|
|
setRooms((prev) => [...prev, room]);
|
|
setCreateOpen(false);
|
|
setNewName("");
|
|
setActiveRoom(room);
|
|
} catch {
|
|
notify.error(t("meetings.createFailedTitle"), t("meetings.createFailedBody"));
|
|
} finally {
|
|
setCreating(false);
|
|
}
|
|
};
|
|
|
|
const confirmDeleteRoom = async () => {
|
|
if (!roomToDelete || deleting) return;
|
|
setDeleting(true);
|
|
try {
|
|
await deleteMeetingRoom(roomToDelete.id);
|
|
setRooms((prev) => prev.filter((r) => r.id !== roomToDelete.id));
|
|
if (activeRoom?.id === roomToDelete.id) setActiveRoom(null);
|
|
setRoomToDelete(null);
|
|
} catch {
|
|
notify.error(t("meetings.deleteFailedTitle"), t("meetings.deleteFailedBody"));
|
|
} finally {
|
|
setDeleting(false);
|
|
}
|
|
};
|
|
|
|
const meetingDays = useMemo(
|
|
() => events.map((e) => new Date(`${e.date}T00:00:00`)),
|
|
[events],
|
|
);
|
|
const dayEvents = useMemo(
|
|
() =>
|
|
events
|
|
.filter((e) => e.date === keyOf(selectedDay))
|
|
.sort((a, b) => a.time.localeCompare(b.time)),
|
|
[events, selectedDay],
|
|
);
|
|
|
|
// Midnight today — used to disable past calendar dates and filter "upcoming".
|
|
const today = useMemo(() => {
|
|
const d = new Date();
|
|
d.setHours(0, 0, 0, 0);
|
|
return d;
|
|
}, []);
|
|
// Next few meetings from today onward, soonest first.
|
|
const upcoming = useMemo(() => {
|
|
const now = new Date();
|
|
return events
|
|
.filter((e) => new Date(`${e.date}T${e.time}`) >= now)
|
|
.sort((a, b) =>
|
|
`${a.date}T${a.time}`.localeCompare(`${b.date}T${b.time}`),
|
|
)
|
|
.slice(0, 4);
|
|
}, [events]);
|
|
|
|
return (
|
|
<div className="flex h-full w-full flex-col gap-3 p-4">
|
|
{/* Header: Rooms / Calendar tabs */}
|
|
<div className="flex items-center gap-1 rounded-full border bg-muted/40 p-1 self-start">
|
|
<button
|
|
className={cn(
|
|
"flex items-center gap-1.5 rounded-full px-3 py-1 font-medium text-sm transition-colors",
|
|
tab === "rooms"
|
|
? "bg-background text-foreground shadow-sm"
|
|
: "text-muted-foreground hover:text-foreground",
|
|
)}
|
|
onClick={() => setTab("rooms")}
|
|
type="button"
|
|
>
|
|
<Video className="size-4" />
|
|
{t("meetings.rooms")}
|
|
</button>
|
|
<button
|
|
className={cn(
|
|
"flex items-center gap-1.5 rounded-full px-3 py-1 font-medium text-sm transition-colors",
|
|
tab === "calendar"
|
|
? "bg-background text-foreground shadow-sm"
|
|
: "text-muted-foreground hover:text-foreground",
|
|
)}
|
|
onClick={() => setTab("calendar")}
|
|
type="button"
|
|
>
|
|
<CalendarDays className="size-4" />
|
|
{t("meetings.calendar")}
|
|
</button>
|
|
</div>
|
|
|
|
{tab === "rooms" ? (
|
|
<div className="flex min-h-0 flex-1 gap-4">
|
|
{/* Room (channel) list */}
|
|
<aside className="flex w-64 shrink-0 flex-col overflow-hidden rounded-2xl border bg-card/30">
|
|
<div className="flex items-center justify-between gap-2 border-border border-b px-4 py-3">
|
|
<h1 className="font-semibold text-base tracking-tight">
|
|
{t("meetings.rooms")}
|
|
</h1>
|
|
<Button
|
|
aria-label={t("meetings.newRoom")}
|
|
onClick={() => setCreateOpen(true)}
|
|
size="icon-sm"
|
|
type="button"
|
|
variant="ghost"
|
|
>
|
|
<Plus className="size-4" />
|
|
</Button>
|
|
</div>
|
|
<div className="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto p-2">
|
|
{rooms.length === 0 ? (
|
|
<p className="px-2 py-1.5 text-muted-foreground text-sm">
|
|
{t("meetings.noRooms")}
|
|
</p>
|
|
) : (
|
|
rooms.map((room) => {
|
|
const count = presence[room.id] ?? 0;
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"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-start"
|
|
onClick={() => setActiveRoom(room)}
|
|
type="button"
|
|
>
|
|
<Video className="size-4 shrink-0 text-muted-foreground" />
|
|
<span className="min-w-0 flex-1 truncate text-foreground text-sm">
|
|
{room.name}
|
|
</span>
|
|
{count > 0 && (
|
|
<span className="flex items-center gap-1 rounded-full bg-success/15 px-1.5 text-success text-xs">
|
|
<Users className="size-3" />
|
|
{count}
|
|
</span>
|
|
)}
|
|
</button>
|
|
<button
|
|
aria-label={t("meetings.deleteRoom")}
|
|
className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-all hover:bg-destructive/10 hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100"
|
|
onClick={() => setRoomToDelete(room)}
|
|
type="button"
|
|
>
|
|
<Trash2 className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</aside>
|
|
|
|
{/* Live call or lobby */}
|
|
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
|
{activeRoom ? (
|
|
<MeetingRoom
|
|
key={activeRoom.id}
|
|
onLeave={() => setActiveRoom(null)}
|
|
roomId={activeRoom.id}
|
|
roomName={activeRoom.name}
|
|
selfName={selfName}
|
|
/>
|
|
) : (
|
|
<div className="flex flex-1 items-center justify-center rounded-2xl border bg-card/30">
|
|
<Empty>
|
|
<EmptyHeader>
|
|
<EmptyMedia variant="icon">
|
|
<Video />
|
|
</EmptyMedia>
|
|
<EmptyTitle>{t("meetings.emptyTitle")}</EmptyTitle>
|
|
<EmptyDescription>
|
|
{t("meetings.emptyDescription")}
|
|
</EmptyDescription>
|
|
</EmptyHeader>
|
|
<EmptyContent>
|
|
<Button
|
|
onClick={() => setCreateOpen(true)}
|
|
type="button"
|
|
variant="outline"
|
|
>
|
|
<Plus className="size-4" />
|
|
{t("meetings.newRoom")}
|
|
</Button>
|
|
</EmptyContent>
|
|
</Empty>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
// Calendar tab
|
|
<div className="flex min-h-0 flex-1 gap-4">
|
|
<div className="flex shrink-0 flex-col gap-3 overflow-y-auto rounded-2xl border bg-card/30 p-3">
|
|
<Calendar
|
|
disabled={{ before: today }}
|
|
mode="single"
|
|
modifiers={{ hasMeeting: meetingDays }}
|
|
modifiersClassNames={{
|
|
hasMeeting:
|
|
"relative after:absolute after:bottom-1 after:left-1/2 after:size-1 after:-translate-x-1/2 after:rounded-full after:bg-primary",
|
|
}}
|
|
onSelect={(d) => d && setSelectedDay(d)}
|
|
required
|
|
selected={selectedDay}
|
|
/>
|
|
<Button onClick={() => setScheduleOpen(true)} type="button">
|
|
<Plus className="size-4" />
|
|
{t("meetings.schedule.cta")}
|
|
</Button>
|
|
|
|
<div className="flex min-h-0 flex-col gap-1.5">
|
|
<span className="px-1 font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
|
{t("meetings.upcoming.title")}
|
|
</span>
|
|
{upcoming.length === 0 ? (
|
|
<p className="px-1 py-2 text-muted-foreground text-xs">
|
|
{t("meetings.upcoming.empty")}
|
|
</p>
|
|
) : (
|
|
<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-start transition-colors hover:bg-accent/50"
|
|
key={e.id}
|
|
onClick={() => setSelectedDay(new Date(`${e.date}T00:00:00`))}
|
|
type="button"
|
|
>
|
|
<span className="truncate font-medium text-foreground text-sm">
|
|
{e.title}
|
|
</span>
|
|
<span className="text-muted-foreground text-xs tabular-nums">
|
|
{new Date(`${e.date}T00:00:00`).toLocaleDateString(
|
|
"en-US",
|
|
{ month: "short", day: "numeric" },
|
|
)}{" "}
|
|
· {e.time}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex min-w-0 flex-1 flex-col overflow-hidden rounded-2xl border bg-card/30">
|
|
<div className="border-border border-b px-4 py-3">
|
|
<h2 className="font-semibold text-base tracking-tight">
|
|
{selectedDay.toLocaleDateString("en-US", {
|
|
weekday: "long",
|
|
month: "long",
|
|
day: "numeric",
|
|
})}
|
|
</h2>
|
|
</div>
|
|
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto p-3">
|
|
{dayEvents.length === 0 ? (
|
|
<Empty className="border-0">
|
|
<EmptyHeader>
|
|
<EmptyMedia variant="icon">
|
|
<CalendarDays />
|
|
</EmptyMedia>
|
|
<EmptyTitle>{t("meetings.calendarEmpty")}</EmptyTitle>
|
|
<EmptyDescription>
|
|
{t("meetings.calendarEmptyHint")}
|
|
</EmptyDescription>
|
|
</EmptyHeader>
|
|
<EmptyContent>
|
|
<Button
|
|
onClick={() => setScheduleOpen(true)}
|
|
type="button"
|
|
variant="outline"
|
|
>
|
|
<Plus className="size-4" />
|
|
{t("meetings.schedule.cta")}
|
|
</Button>
|
|
</EmptyContent>
|
|
</Empty>
|
|
) : (
|
|
dayEvents.map((e) => (
|
|
<div
|
|
className="flex items-start gap-3 rounded-xl border bg-card px-3 py-2.5"
|
|
key={e.id}
|
|
>
|
|
<span className="font-medium text-foreground text-sm tabular-nums">
|
|
{e.time}
|
|
</span>
|
|
<div className="flex min-w-0 flex-1 flex-col">
|
|
<span className="truncate font-medium text-foreground text-sm">
|
|
{e.title}
|
|
</span>
|
|
<span className="truncate text-muted-foreground text-xs">
|
|
{e.participantNames.join(", ")}
|
|
</span>
|
|
</div>
|
|
<Avatar className="size-7">
|
|
<AvatarFallback className="text-xs">
|
|
{e.participantNames.length}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Create room dialog */}
|
|
<Dialog onOpenChange={setCreateOpen} open={createOpen}>
|
|
<DialogPopup className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>{t("meetings.newRoom")}</DialogTitle>
|
|
<DialogDescription>{t("meetings.newRoomDescription")}</DialogDescription>
|
|
</DialogHeader>
|
|
<form className="contents" onSubmit={createRoom}>
|
|
<DialogPanel>
|
|
<Input
|
|
aria-label={t("meetings.roomNameLabel")}
|
|
onChange={(e) => setNewName(e.target.value)}
|
|
placeholder={t("meetings.roomNamePlaceholder")}
|
|
value={newName}
|
|
/>
|
|
</DialogPanel>
|
|
<DialogFooter>
|
|
<DialogClose render={<Button type="button" variant="outline" />}>
|
|
{t("meetings.cancel")}
|
|
</DialogClose>
|
|
<Button disabled={!newName.trim() || creating} type="submit">
|
|
{creating ? t("meetings.creating") : t("meetings.create")}
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogPopup>
|
|
</Dialog>
|
|
|
|
<Dialog
|
|
onOpenChange={(open) => !open && setRoomToDelete(null)}
|
|
open={roomToDelete !== null}
|
|
>
|
|
<DialogPopup className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>{t("meetings.deleteRoomConfirmTitle")}</DialogTitle>
|
|
<DialogDescription>
|
|
{t("meetings.deleteRoomConfirmBody", {
|
|
name: roomToDelete?.name ?? "",
|
|
})}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<DialogClose render={<Button type="button" variant="outline" />}>
|
|
{t("meetings.cancel")}
|
|
</DialogClose>
|
|
<Button
|
|
disabled={deleting}
|
|
onClick={confirmDeleteRoom}
|
|
type="button"
|
|
variant="destructive"
|
|
>
|
|
{deleting ? t("meetings.deleting") : t("meetings.deleteRoom")}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogPopup>
|
|
</Dialog>
|
|
|
|
<ScheduleMeetingDialog
|
|
defaultDate={keyOf(selectedDay)}
|
|
defaultParticipants={deepLinkWith ? [deepLinkWith] : undefined}
|
|
onCreated={loadEvents}
|
|
onOpenChange={setScheduleOpen}
|
|
open={scheduleOpen}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|