mirror of
https://github.com/temetro/temetro.git
synced 2026-07-27 12:18:56 +00:00
feat(meetings): live invite, scheduling calendar, Discord-style redesign
- Live invite: ring a clinic member into a room (socket call:invite + a bell notification); the invitee gets a toast with a Join action. Notifications of type "meeting" deep-link to /messages/meetings?room=, which auto-joins. - Scheduling: new scheduled_meetings table + /api/meetings/events (list mine, create, delete); a Calendar tab on the Meetings page with a month picker (meeting-day dots), the day's agenda, and a Schedule-meeting dialog (title/date/time/participants). - Redesign: rounded control bar with tooltips (mic/cam/screen/invite + separated red Leave), speaking ring on tiles (Web Audio), and live room-occupancy counts via call:presence broadcasts. Migration 0025. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE "scheduled_meetings" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"date" text NOT NULL,
|
||||
"time" text NOT NULL,
|
||||
"participants" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"created_by" text,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "scheduled_meetings" ADD CONSTRAINT "scheduled_meetings_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "scheduled_meetings" ADD CONSTRAINT "scheduled_meetings_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "scheduled_meetings_org_idx" ON "scheduled_meetings" USING btree ("organization_id");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -176,6 +176,13 @@
|
||||
"when": 1781891060177,
|
||||
"tag": "0024_skinny_iron_fist",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 25,
|
||||
"version": "7",
|
||||
"when": 1781910033543,
|
||||
"tag": "0025_nice_paladin",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,11 @@
|
||||
import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||
import {
|
||||
index,
|
||||
jsonb,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
import { organization, user } from "./auth.js";
|
||||
|
||||
@@ -21,3 +28,25 @@ export const meetingRooms = pgTable(
|
||||
},
|
||||
(table) => [index("meeting_rooms_org_idx").on(table.organizationId)],
|
||||
);
|
||||
|
||||
// A scheduled staff meeting (calendar event), scoped to a clinic. `participants`
|
||||
// holds the invited staff user ids; `date`/`time` are local strings (YYYY-MM-DD /
|
||||
// HH:mm) like appointments, to avoid timezone drift on the calendar.
|
||||
export const scheduledMeetings = pgTable(
|
||||
"scheduled_meetings",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
title: text("title").notNull(),
|
||||
date: text("date").notNull(),
|
||||
time: text("time").notNull(),
|
||||
participants: jsonb("participants").$type<string[]>().notNull().default([]),
|
||||
createdBy: text("created_by").references(() => user.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
},
|
||||
(table) => [index("scheduled_meetings_org_idx").on(table.organizationId)],
|
||||
);
|
||||
|
||||
+50
-1
@@ -15,6 +15,7 @@ let io: Server | null = null;
|
||||
const userRoom = (userId: string) => `user:${userId}`;
|
||||
const convRoom = (conversationId: string) => `conv:${conversationId}`;
|
||||
const callRoom = (roomId: string) => `call:${roomId}`;
|
||||
const orgRoom = (orgId: string) => `org:${orgId}`;
|
||||
|
||||
// Mesh WebRTC tops out around four peers (each sends its stream to every other);
|
||||
// past that the room is closed to new joiners.
|
||||
@@ -69,8 +70,9 @@ export function initRealtime(httpServer: HttpServer): Server {
|
||||
const userName: string = socket.data.userName;
|
||||
const orgId: string | null = socket.data.orgId;
|
||||
|
||||
// Personal room for notifications.
|
||||
// Personal room for notifications; clinic room for call presence broadcasts.
|
||||
socket.join(userRoom(userId));
|
||||
if (orgId) socket.join(orgRoom(orgId));
|
||||
|
||||
socket.on(
|
||||
"conversation:join",
|
||||
@@ -151,6 +153,14 @@ export function initRealtime(httpServer: HttpServer): Server {
|
||||
// org-scoped: a join is authorized against the clinic's meeting_rooms.
|
||||
const joinedCallRooms = new Set<string>();
|
||||
|
||||
// Broadcast a room's live occupancy to the whole clinic so the meetings
|
||||
// room list can show "N in call".
|
||||
const emitPresence = (roomId: string) => {
|
||||
if (!orgId) return;
|
||||
const count = callParticipants.get(roomId)?.size ?? 0;
|
||||
io?.to(orgRoom(orgId)).emit("call:presence", { roomId, count });
|
||||
};
|
||||
|
||||
const leaveCall = (roomId: string) => {
|
||||
if (!joinedCallRooms.has(roomId)) return;
|
||||
joinedCallRooms.delete(roomId);
|
||||
@@ -160,6 +170,7 @@ export function initRealtime(httpServer: HttpServer): Server {
|
||||
callParticipants.delete(roomId);
|
||||
}
|
||||
socket.to(callRoom(roomId)).emit("call:peer-left", { socketId: socket.id });
|
||||
emitPresence(roomId);
|
||||
};
|
||||
|
||||
socket.on("call:join", async (roomId: unknown, ack?: Ack) => {
|
||||
@@ -181,6 +192,7 @@ export function initRealtime(httpServer: HttpServer): Server {
|
||||
callParticipants.set(id, peers);
|
||||
// Tell existing peers a newcomer arrived; the newcomer initiates offers.
|
||||
socket.to(callRoom(id)).emit("call:peer-joined", me);
|
||||
emitPresence(id);
|
||||
// Reply with the peers already present (excluding self).
|
||||
ack?.({
|
||||
ok: true,
|
||||
@@ -191,6 +203,42 @@ export function initRealtime(httpServer: HttpServer): Server {
|
||||
}
|
||||
});
|
||||
|
||||
// Ring a clinic member into a room: push a live invite + a bell notification.
|
||||
socket.on(
|
||||
"call:invite",
|
||||
async (payload: { roomId?: string; toUserId?: string }) => {
|
||||
try {
|
||||
const roomId = String(payload?.roomId ?? "");
|
||||
const toUserId = String(payload?.toUserId ?? "");
|
||||
if (!(roomId && toUserId && orgId)) return;
|
||||
if (!(await meetings.roomExists(orgId, roomId))) return;
|
||||
const room = (await meetings.listRooms(orgId)).find(
|
||||
(r) => r.id === roomId,
|
||||
);
|
||||
const roomName = room?.name ?? "";
|
||||
emitToUser(toUserId, "call:invite", {
|
||||
roomId,
|
||||
roomName,
|
||||
fromName: userName,
|
||||
});
|
||||
const notification = await createNotification({
|
||||
orgId,
|
||||
userId: toUserId,
|
||||
type: "meeting",
|
||||
text: `${userName} invited you to a call`,
|
||||
entityType: "meeting",
|
||||
entityId: roomId,
|
||||
actorName: userName,
|
||||
});
|
||||
if (notification) {
|
||||
emitToUser(toUserId, "notification:new", notification);
|
||||
}
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Relay an SDP offer/answer or ICE candidate to a specific peer socket.
|
||||
socket.on(
|
||||
"call:signal",
|
||||
@@ -217,6 +265,7 @@ export function initRealtime(httpServer: HttpServer): Server {
|
||||
socket
|
||||
.to(callRoom(roomId))
|
||||
.emit("call:peer-left", { socketId: socket.id });
|
||||
emitPresence(roomId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,6 +39,51 @@ meetingsRouter.post("/", async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Scheduled meetings (calendar) -----------------------------------------
|
||||
|
||||
meetingsRouter.get("/events", async (req, res, next) => {
|
||||
try {
|
||||
res.json(await meetings.listMeetingEvents(req.organizationId!, req.user!.id));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
const eventSchema = z.object({
|
||||
title: z.string().trim().min(1).max(120),
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
time: z.string().regex(/^\d{2}:\d{2}$/),
|
||||
participants: z.array(z.string()).max(50).default([]),
|
||||
});
|
||||
|
||||
meetingsRouter.post("/events", async (req, res, next) => {
|
||||
try {
|
||||
const input = eventSchema.parse(req.body);
|
||||
const event = await meetings.createMeetingEvent(
|
||||
req.organizationId!,
|
||||
req.user!.id,
|
||||
input,
|
||||
);
|
||||
res.status(201).json(event);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
meetingsRouter.delete("/events/:id", async (req, res, next) => {
|
||||
try {
|
||||
const ok = await meetings.deleteMeetingEvent(
|
||||
req.organizationId!,
|
||||
req.user!.id,
|
||||
String(req.params.id ?? ""),
|
||||
);
|
||||
if (!ok) throw new HttpError(404, "Meeting not found.");
|
||||
res.status(204).end();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
meetingsRouter.delete("/:id", async (req, res, next) => {
|
||||
try {
|
||||
const ok = await meetings.deleteRoom(
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { and, asc, eq, inArray, or, sql } from "drizzle-orm";
|
||||
|
||||
import { db } from "../db/index.js";
|
||||
import { meetingRooms } from "../db/schema/meetings.js";
|
||||
import { user } from "../db/schema/auth.js";
|
||||
import { meetingRooms, scheduledMeetings } from "../db/schema/meetings.js";
|
||||
|
||||
export type MeetingRoom = {
|
||||
id: string;
|
||||
@@ -67,3 +68,99 @@ export async function roomExists(orgId: string, roomId: string): Promise<boolean
|
||||
);
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
// --- Scheduled meetings (calendar) -----------------------------------------
|
||||
|
||||
export type ScheduledMeeting = {
|
||||
id: string;
|
||||
title: string;
|
||||
date: string;
|
||||
time: string;
|
||||
participants: string[];
|
||||
participantNames: string[];
|
||||
createdBy: string | null;
|
||||
};
|
||||
|
||||
// Meetings the user is part of (creator or invited participant), with the
|
||||
// participants' display names resolved for the calendar.
|
||||
export async function listMeetingEvents(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
): Promise<ScheduledMeeting[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(scheduledMeetings)
|
||||
.where(
|
||||
and(
|
||||
eq(scheduledMeetings.organizationId, orgId),
|
||||
or(
|
||||
eq(scheduledMeetings.createdBy, userId),
|
||||
// `participants` is a JSONB array of user ids.
|
||||
sql`${scheduledMeetings.participants} @> ${JSON.stringify([userId])}::jsonb`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(scheduledMeetings.date), asc(scheduledMeetings.time));
|
||||
|
||||
// Resolve participant names in one query.
|
||||
const ids = [...new Set(rows.flatMap((r) => r.participants))];
|
||||
const nameById = new Map<string, string>();
|
||||
if (ids.length > 0) {
|
||||
const users = await db
|
||||
.select({ id: user.id, name: user.name })
|
||||
.from(user)
|
||||
.where(inArray(user.id, ids));
|
||||
for (const u of users) nameById.set(u.id, u.name);
|
||||
}
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
date: r.date,
|
||||
time: r.time,
|
||||
participants: r.participants,
|
||||
participantNames: r.participants.map((id) => nameById.get(id) ?? "—"),
|
||||
createdBy: r.createdBy,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function createMeetingEvent(
|
||||
orgId: string,
|
||||
createdBy: string,
|
||||
input: { title: string; date: string; time: string; participants: string[] },
|
||||
): Promise<ScheduledMeeting> {
|
||||
// The creator is always a participant.
|
||||
const participants = [...new Set([createdBy, ...input.participants])];
|
||||
const [row] = await db
|
||||
.insert(scheduledMeetings)
|
||||
.values({
|
||||
organizationId: orgId,
|
||||
title: input.title,
|
||||
date: input.date,
|
||||
time: input.time,
|
||||
participants,
|
||||
createdBy,
|
||||
})
|
||||
.returning({ id: scheduledMeetings.id });
|
||||
const events = await listMeetingEvents(orgId, createdBy);
|
||||
return events.find((e) => e.id === row!.id)!;
|
||||
}
|
||||
|
||||
export async function deleteMeetingEvent(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
id: string,
|
||||
): Promise<boolean> {
|
||||
// Only the creator can delete.
|
||||
const deleted = await db
|
||||
.delete(scheduledMeetings)
|
||||
.where(
|
||||
and(
|
||||
eq(scheduledMeetings.organizationId, orgId),
|
||||
eq(scheduledMeetings.id, id),
|
||||
eq(scheduledMeetings.createdBy, userId),
|
||||
),
|
||||
)
|
||||
.returning({ id: scheduledMeetings.id });
|
||||
return deleted.length > 0;
|
||||
}
|
||||
|
||||
@@ -5,15 +5,35 @@ import {
|
||||
MicOff,
|
||||
MonitorUp,
|
||||
PhoneOff,
|
||||
Search,
|
||||
UserPlus,
|
||||
Video as VideoIcon,
|
||||
VideoOff,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useSpeaking } from "@/components/meetings/use-audio-level";
|
||||
import { useWebRtcMesh } from "@/components/meetings/use-webrtc-mesh";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogPanel,
|
||||
DialogPopup,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipPopup,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { listClinicMembers, type Participant } from "@/lib/messages";
|
||||
import { getSocket } from "@/lib/socket";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function initials(name: string): string {
|
||||
@@ -23,7 +43,8 @@ function initials(name: string): string {
|
||||
return (parts[0]![0]! + parts.at(-1)![0]!).toUpperCase();
|
||||
}
|
||||
|
||||
// One participant tile: shows their video, or an avatar when the camera is off.
|
||||
// One participant tile: video when the camera is on, an avatar otherwise, with a
|
||||
// green speaking ring driven by the stream's audio level.
|
||||
function VideoTile({
|
||||
stream,
|
||||
label,
|
||||
@@ -36,13 +57,21 @@ function VideoTile({
|
||||
showVideo: boolean;
|
||||
}) {
|
||||
const ref = useRef<HTMLVideoElement>(null);
|
||||
// Analyse the stream's audio for the speaking ring (muting only affects
|
||||
// playback, so the local tile still gets a ring when you talk).
|
||||
const speaking = useSpeaking(stream);
|
||||
useEffect(() => {
|
||||
if (ref.current && ref.current.srcObject !== stream) {
|
||||
ref.current.srcObject = stream;
|
||||
}
|
||||
}, [stream]);
|
||||
return (
|
||||
<div className="relative aspect-video overflow-hidden rounded-2xl border bg-muted">
|
||||
<div
|
||||
className={cn(
|
||||
"relative aspect-video overflow-hidden rounded-2xl border-2 bg-muted transition-colors",
|
||||
speaking ? "border-success" : "border-transparent",
|
||||
)}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
||||
<video
|
||||
autoPlay
|
||||
@@ -65,6 +94,40 @@ function VideoTile({
|
||||
);
|
||||
}
|
||||
|
||||
// A round control-bar button with a tooltip label.
|
||||
function ControlButton({
|
||||
label,
|
||||
onClick,
|
||||
active,
|
||||
variant = "secondary",
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
active?: boolean;
|
||||
variant?: "secondary" | "outline" | "default" | "destructive";
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label={label}
|
||||
className="size-12 rounded-full"
|
||||
onClick={onClick}
|
||||
size="icon"
|
||||
variant={active === false ? "outline" : variant}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</TooltipTrigger>
|
||||
<TooltipPopup>{label}</TooltipPopup>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function MeetingRoom({
|
||||
roomId,
|
||||
roomName,
|
||||
@@ -77,6 +140,8 @@ export function MeetingRoom({
|
||||
onLeave: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { data: session } = authClient.useSession();
|
||||
const myId = session?.user?.id ?? "";
|
||||
const {
|
||||
localStream,
|
||||
peers,
|
||||
@@ -90,10 +155,31 @@ export function MeetingRoom({
|
||||
maxPeers,
|
||||
} = useWebRtcMesh(roomId);
|
||||
|
||||
const leave = () => {
|
||||
onLeave();
|
||||
// Invite picker.
|
||||
const [inviteOpen, setInviteOpen] = useState(false);
|
||||
const [members, setMembers] = useState<Participant[]>([]);
|
||||
const [memberQuery, setMemberQuery] = useState("");
|
||||
const [invited, setInvited] = useState<Set<string>>(new Set());
|
||||
|
||||
const openInvite = () => {
|
||||
setInviteOpen(true);
|
||||
setMemberQuery("");
|
||||
listClinicMembers()
|
||||
.then(setMembers)
|
||||
.catch(() => setMembers([]));
|
||||
};
|
||||
|
||||
const invite = (userId: string) => {
|
||||
getSocket().emit("call:invite", { roomId, toUserId: userId });
|
||||
setInvited((prev) => new Set(prev).add(userId));
|
||||
};
|
||||
|
||||
const visibleMembers = members.filter(
|
||||
(m) =>
|
||||
m.id !== myId &&
|
||||
m.name.toLowerCase().includes(memberQuery.trim().toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-3 rounded-2xl border bg-card/30 px-4 py-3">
|
||||
@@ -116,13 +202,11 @@ export function MeetingRoom({
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto rounded-2xl border bg-card/30 p-4">
|
||||
{joinState === "full" ? (
|
||||
{joinState === "full" || joinState === "error" ? (
|
||||
<div className="flex h-full items-center justify-center text-center text-muted-foreground text-sm">
|
||||
{t("meetings.roomFull", { max: maxPeers })}
|
||||
</div>
|
||||
) : joinState === "error" ? (
|
||||
<div className="flex h-full items-center justify-center text-center text-muted-foreground text-sm">
|
||||
{t("meetings.callError")}
|
||||
{joinState === "full"
|
||||
? t("meetings.roomFull", { max: maxPeers })
|
||||
: t("meetings.callError")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
@@ -145,48 +229,102 @@ export function MeetingRoom({
|
||||
</div>
|
||||
|
||||
{/* Discord-style control bar */}
|
||||
<div className="flex items-center justify-center gap-2 rounded-full border bg-card/60 p-2 backdrop-blur">
|
||||
<Button
|
||||
aria-label={micOn ? t("meetings.muteMic") : t("meetings.unmuteMic")}
|
||||
className="size-11 rounded-full"
|
||||
onClick={toggleMic}
|
||||
size="icon"
|
||||
variant={micOn ? "secondary" : "outline"}
|
||||
>
|
||||
{micOn ? <Mic className="size-5" /> : <MicOff className="size-5" />}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={camOn ? t("meetings.stopVideo") : t("meetings.startVideo")}
|
||||
className="size-11 rounded-full"
|
||||
onClick={toggleCam}
|
||||
size="icon"
|
||||
variant={camOn ? "secondary" : "outline"}
|
||||
>
|
||||
{camOn ? (
|
||||
<VideoIcon className="size-5" />
|
||||
) : (
|
||||
<VideoOff className="size-5" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={t("meetings.shareScreen")}
|
||||
className="size-11 rounded-full"
|
||||
onClick={() => void toggleScreen()}
|
||||
size="icon"
|
||||
variant={screenOn ? "default" : "secondary"}
|
||||
>
|
||||
<MonitorUp className="size-5" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={t("meetings.leave")}
|
||||
className="size-11 rounded-full"
|
||||
onClick={leave}
|
||||
size="icon"
|
||||
<div className="flex items-center justify-center gap-3 rounded-full border bg-card/60 px-4 py-2 backdrop-blur">
|
||||
<div className="flex items-center gap-2">
|
||||
<ControlButton
|
||||
active={micOn}
|
||||
label={micOn ? t("meetings.muteMic") : t("meetings.unmuteMic")}
|
||||
onClick={toggleMic}
|
||||
>
|
||||
{micOn ? <Mic className="size-5" /> : <MicOff className="size-5" />}
|
||||
</ControlButton>
|
||||
<ControlButton
|
||||
active={camOn}
|
||||
label={camOn ? t("meetings.stopVideo") : t("meetings.startVideo")}
|
||||
onClick={toggleCam}
|
||||
>
|
||||
{camOn ? (
|
||||
<VideoIcon className="size-5" />
|
||||
) : (
|
||||
<VideoOff className="size-5" />
|
||||
)}
|
||||
</ControlButton>
|
||||
<ControlButton
|
||||
label={t("meetings.shareScreen")}
|
||||
onClick={() => void toggleScreen()}
|
||||
variant={screenOn ? "default" : "secondary"}
|
||||
>
|
||||
<MonitorUp className="size-5" />
|
||||
</ControlButton>
|
||||
<ControlButton label={t("meetings.invite.add")} onClick={openInvite}>
|
||||
<UserPlus className="size-5" />
|
||||
</ControlButton>
|
||||
</div>
|
||||
<div className="mx-1 h-8 w-px bg-border" />
|
||||
<ControlButton
|
||||
label={t("meetings.leave")}
|
||||
onClick={onLeave}
|
||||
variant="destructive"
|
||||
>
|
||||
<PhoneOff className="size-5" />
|
||||
</Button>
|
||||
</ControlButton>
|
||||
</div>
|
||||
|
||||
<Dialog onOpenChange={setInviteOpen} open={inviteOpen}>
|
||||
<DialogPopup className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("meetings.invite.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("meetings.invite.description", { room: roomName })}
|
||||
</DialogDescription>
|
||||
</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" />
|
||||
<Input
|
||||
aria-label={t("meetings.invite.search")}
|
||||
className="pl-9"
|
||||
onChange={(e) => setMemberQuery(e.target.value)}
|
||||
placeholder={t("meetings.invite.search")}
|
||||
size="sm"
|
||||
value={memberQuery}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex max-h-72 flex-col gap-1 overflow-y-auto">
|
||||
{visibleMembers.length === 0 ? (
|
||||
<p className="px-1 py-4 text-center text-muted-foreground text-sm">
|
||||
{t("meetings.invite.noMembers")}
|
||||
</p>
|
||||
) : (
|
||||
visibleMembers.map((m) => (
|
||||
<div
|
||||
className="flex items-center gap-3 rounded-lg px-2 py-2"
|
||||
key={m.id}
|
||||
>
|
||||
<Avatar className="size-8">
|
||||
<AvatarFallback>{initials(m.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="min-w-0 flex-1 truncate text-foreground text-sm">
|
||||
{m.name}
|
||||
</span>
|
||||
<Button
|
||||
disabled={invited.has(m.id)}
|
||||
onClick={() => invite(m.id)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{invited.has(m.id)
|
||||
? t("meetings.invite.invited")
|
||||
: t("meetings.invite.ring")}
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</DialogPopup>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { Plus, Video } from "lucide-react";
|
||||
import { type FormEvent, useEffect, useState } from "react";
|
||||
import { CalendarDays, Plus, 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,
|
||||
@@ -28,31 +32,82 @@ import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import {
|
||||
createMeetingRoom,
|
||||
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");
|
||||
const openedDeepLink = 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);
|
||||
|
||||
// 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(() => {
|
||||
/* api-client redirects on 401 */
|
||||
});
|
||||
.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]);
|
||||
|
||||
const createRoom = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const name = newName.trim();
|
||||
@@ -71,83 +126,207 @@ export function MeetingsView() {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full gap-4 p-4">
|
||||
{/* Left: 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) => (
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2.5 rounded-lg px-2 py-2 text-left transition-colors hover:bg-accent/50",
|
||||
activeRoom?.id === room.id && "bg-accent hover:bg-accent",
|
||||
)}
|
||||
key={room.id}
|
||||
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>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
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],
|
||||
);
|
||||
|
||||
{/* Right: the live call, or a 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>
|
||||
)}
|
||||
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 (
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2.5 rounded-lg px-2 py-2 text-left transition-colors hover:bg-accent/50",
|
||||
activeRoom?.id === room.id && "bg-accent hover:bg-accent",
|
||||
)}
|
||||
key={room.id}
|
||||
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>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</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 rounded-2xl border bg-card/30 p-3">
|
||||
<Calendar
|
||||
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>
|
||||
|
||||
<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 ? (
|
||||
<p className="px-2 py-6 text-center text-muted-foreground text-sm">
|
||||
{t("meetings.calendarEmpty")}
|
||||
</p>
|
||||
) : (
|
||||
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>
|
||||
@@ -174,6 +353,13 @@ export function MeetingsView() {
|
||||
</form>
|
||||
</DialogPopup>
|
||||
</Dialog>
|
||||
|
||||
<ScheduleMeetingDialog
|
||||
defaultDate={keyOf(selectedDay)}
|
||||
onCreated={loadEvents}
|
||||
onOpenChange={setScheduleOpen}
|
||||
open={scheduleOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
"use client";
|
||||
|
||||
import { Check } from "lucide-react";
|
||||
import { type FormEvent, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogPanel,
|
||||
DialogPopup,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { createMeetingEvent } from "@/lib/meetings";
|
||||
import { listClinicMembers, type Participant } from "@/lib/messages";
|
||||
import { notify } from "@/lib/toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function initials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return "?";
|
||||
if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase();
|
||||
return (parts[0]![0]! + parts.at(-1)![0]!).toUpperCase();
|
||||
}
|
||||
|
||||
export function ScheduleMeetingDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
defaultDate,
|
||||
onCreated,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
defaultDate?: string; // YYYY-MM-DD
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { data: session } = authClient.useSession();
|
||||
const myId = session?.user?.id ?? "";
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [date, setDate] = useState(defaultDate ?? "");
|
||||
const [time, setTime] = useState("09:00");
|
||||
const [members, setMembers] = useState<Participant[]>([]);
|
||||
const [picked, setPicked] = useState<Set<string>>(new Set());
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setTitle("");
|
||||
setDate(defaultDate ?? "");
|
||||
setTime("09:00");
|
||||
setPicked(new Set());
|
||||
listClinicMembers()
|
||||
.then(setMembers)
|
||||
.catch(() => setMembers([]));
|
||||
}, [open, defaultDate]);
|
||||
|
||||
const toggle = (id: string) =>
|
||||
setPicked((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!(title.trim() && date && time) || saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await createMeetingEvent({
|
||||
title: title.trim(),
|
||||
date,
|
||||
time,
|
||||
participants: [...picked],
|
||||
});
|
||||
onCreated();
|
||||
onOpenChange(false);
|
||||
} catch {
|
||||
notify.error(
|
||||
t("meetings.schedule.failedTitle"),
|
||||
t("meetings.schedule.failedBody"),
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogPopup className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("meetings.schedule.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("meetings.schedule.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form className="contents" onSubmit={submit}>
|
||||
<DialogPanel className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="font-medium text-sm" htmlFor="meeting-title">
|
||||
{t("meetings.schedule.titleLabel")}
|
||||
</label>
|
||||
<Input
|
||||
id="meeting-title"
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={t("meetings.schedule.titlePlaceholder")}
|
||||
value={title}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="font-medium text-sm" htmlFor="meeting-date">
|
||||
{t("meetings.schedule.date")}
|
||||
</label>
|
||||
<Input
|
||||
id="meeting-date"
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
type="date"
|
||||
value={date}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="font-medium text-sm" htmlFor="meeting-time">
|
||||
{t("meetings.schedule.time")}
|
||||
</label>
|
||||
<Input
|
||||
id="meeting-time"
|
||||
onChange={(e) => setTime(e.target.value)}
|
||||
type="time"
|
||||
value={time}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="font-medium text-sm">
|
||||
{t("meetings.schedule.participants")}
|
||||
</span>
|
||||
<div className="flex max-h-48 flex-col gap-1 overflow-y-auto rounded-2xl border p-1">
|
||||
{members.filter((m) => m.id !== myId).length === 0 ? (
|
||||
<p className="px-2 py-3 text-center text-muted-foreground text-sm">
|
||||
{t("meetings.invite.noMembers")}
|
||||
</p>
|
||||
) : (
|
||||
members
|
||||
.filter((m) => m.id !== myId)
|
||||
.map((m) => {
|
||||
const on = picked.has(m.id);
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-accent/50",
|
||||
on && "bg-accent",
|
||||
)}
|
||||
key={m.id}
|
||||
onClick={() => toggle(m.id)}
|
||||
type="button"
|
||||
>
|
||||
<Avatar className="size-7">
|
||||
<AvatarFallback className="text-xs">
|
||||
{initials(m.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="min-w-0 flex-1 truncate text-sm">
|
||||
{m.name}
|
||||
</span>
|
||||
{on && <Check className="size-4 text-primary" />}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
<DialogFooter>
|
||||
<DialogClose render={<Button type="button" variant="outline" />}>
|
||||
{t("meetings.cancel")}
|
||||
</DialogClose>
|
||||
<Button
|
||||
disabled={!(title.trim() && date && time) || saving}
|
||||
type="submit"
|
||||
>
|
||||
{saving ? t("meetings.schedule.saving") : t("meetings.schedule.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogPopup>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// Returns true while the given stream's audio is above a speaking threshold —
|
||||
// used to draw a Discord-style "speaking" ring around a participant's tile.
|
||||
// Degrades silently if the Web Audio API isn't available.
|
||||
export function useSpeaking(stream: MediaStream | null): boolean {
|
||||
const [speaking, setSpeaking] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!stream || stream.getAudioTracks().length === 0) {
|
||||
setSpeaking(false);
|
||||
return;
|
||||
}
|
||||
let ctx: AudioContext | null = null;
|
||||
let raf = 0;
|
||||
try {
|
||||
ctx = new AudioContext();
|
||||
const source = ctx.createMediaStreamSource(stream);
|
||||
const analyser = ctx.createAnalyser();
|
||||
analyser.fftSize = 512;
|
||||
source.connect(analyser);
|
||||
const data = new Uint8Array(analyser.frequencyBinCount);
|
||||
const tick = () => {
|
||||
analyser.getByteTimeDomainData(data);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const v = (data[i]! - 128) / 128;
|
||||
sum += v * v;
|
||||
}
|
||||
setSpeaking(Math.sqrt(sum / data.length) > 0.045);
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
tick();
|
||||
} catch {
|
||||
/* no Web Audio — no ring */
|
||||
}
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
ctx?.close().catch(() => {});
|
||||
};
|
||||
}, [stream]);
|
||||
|
||||
return speaking;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { toastManager } from "@/components/ui/toast";
|
||||
import { getSocket } from "@/lib/socket";
|
||||
|
||||
type CallInvite = { roomId: string; roomName: string; fromName: string };
|
||||
|
||||
// Listens for live "call:invite" events and shows a toast with a Join action
|
||||
// that drops the user into the room. Mounted once in the app shell (the sidebar)
|
||||
// so it works from any page.
|
||||
export function useCallInvites() {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
const socket = getSocket();
|
||||
const onInvite = ({ roomId, roomName, fromName }: CallInvite) => {
|
||||
toastManager.add({
|
||||
type: "info",
|
||||
title: t("meetings.invite.toastTitle", { name: fromName }),
|
||||
description: roomName,
|
||||
actionProps: {
|
||||
children: t("meetings.invite.join"),
|
||||
onClick: () =>
|
||||
router.push(
|
||||
`/messages/meetings?room=${encodeURIComponent(roomId)}`,
|
||||
),
|
||||
},
|
||||
});
|
||||
};
|
||||
socket.on("call:invite", onInvite);
|
||||
return () => {
|
||||
socket.off("call:invite", onInvite);
|
||||
};
|
||||
}, [router, t]);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import DashboardNavigation from "@/components/sidebar-02/nav-main";
|
||||
import { NavChatHistory } from "@/components/sidebar-02/nav-chat-history";
|
||||
import { NotificationsPopover } from "@/components/sidebar-02/nav-notifications";
|
||||
import { NavUser } from "@/components/sidebar-02/nav-user";
|
||||
import { useCallInvites } from "@/components/meetings/use-call-invites";
|
||||
|
||||
export function DashboardSidebar() {
|
||||
const { state } = useSidebar();
|
||||
@@ -31,6 +32,8 @@ export function DashboardSidebar() {
|
||||
const role = useActiveRole();
|
||||
const { allowed: aiAllowed } = useAiAccess();
|
||||
const isCollapsed = state === "collapsed";
|
||||
// Ring staff into calls from anywhere in the app.
|
||||
useCallInvites();
|
||||
|
||||
// Hide clinical nav from non-clinical roles (e.g. reception). See lib/roles.ts.
|
||||
// Also drop the AI surfaces ("New chat" + "Analysis") when the clinic's AI
|
||||
|
||||
@@ -737,7 +737,34 @@
|
||||
"startVideo": "Start video",
|
||||
"stopVideo": "Stop video",
|
||||
"shareScreen": "Share screen",
|
||||
"leave": "Leave call"
|
||||
"leave": "Leave call",
|
||||
"calendar": "Calendar",
|
||||
"calendarEmpty": "No meetings on this day.",
|
||||
"invite": {
|
||||
"add": "Add people",
|
||||
"title": "Invite to call",
|
||||
"description": "Ring a team member into {{room}}.",
|
||||
"search": "Search team…",
|
||||
"noMembers": "No other members.",
|
||||
"ring": "Ring",
|
||||
"invited": "Invited",
|
||||
"join": "Join",
|
||||
"toastTitle": "{{name}} invited you to a call"
|
||||
},
|
||||
"schedule": {
|
||||
"cta": "Schedule meeting",
|
||||
"title": "Schedule a meeting",
|
||||
"description": "Plan a meeting and invite your team.",
|
||||
"titleLabel": "Title",
|
||||
"titlePlaceholder": "e.g. Weekly sync",
|
||||
"date": "Date",
|
||||
"time": "Time",
|
||||
"participants": "Participants",
|
||||
"save": "Schedule",
|
||||
"saving": "Scheduling…",
|
||||
"failedTitle": "Couldn't schedule meeting",
|
||||
"failedBody": "Please try again."
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"inbox": "Inbox",
|
||||
|
||||
@@ -35,3 +35,37 @@ export type CallPeer = {
|
||||
userId: string;
|
||||
userName: string;
|
||||
};
|
||||
|
||||
// --- Scheduled meetings (calendar) -----------------------------------------
|
||||
|
||||
export type ScheduledMeeting = {
|
||||
id: string;
|
||||
title: string;
|
||||
date: string; // YYYY-MM-DD
|
||||
time: string; // HH:mm
|
||||
participants: string[];
|
||||
participantNames: string[];
|
||||
createdBy: string | null;
|
||||
};
|
||||
|
||||
export function listMeetingEvents(): Promise<ScheduledMeeting[]> {
|
||||
return apiFetch<ScheduledMeeting[]>("/api/meetings/events");
|
||||
}
|
||||
|
||||
export function createMeetingEvent(input: {
|
||||
title: string;
|
||||
date: string;
|
||||
time: string;
|
||||
participants: string[];
|
||||
}): Promise<ScheduledMeeting> {
|
||||
return apiFetch<ScheduledMeeting>("/api/meetings/events", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteMeetingEvent(id: string): Promise<void> {
|
||||
return apiFetch<void>(`/api/meetings/events/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ export function notificationHref(n: Notification): string | null {
|
||||
switch (n.entityType) {
|
||||
case "conversation":
|
||||
return `/messages?conversation=${encodeURIComponent(n.entityId)}`;
|
||||
case "meeting":
|
||||
return `/messages/meetings?room=${encodeURIComponent(n.entityId)}`;
|
||||
case "patient":
|
||||
return `/patients?file=${encodeURIComponent(n.entityId)}`;
|
||||
default:
|
||||
|
||||
Reference in New Issue
Block a user