Modified by www.SourceFiles.app
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Plus, MessageSquare } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/_authenticated/messages")({
|
||||
head: () => ({ meta: [{ title: "Messages — School Portal" }] }),
|
||||
component: MessagesPage,
|
||||
});
|
||||
|
||||
function MessagesPage() {
|
||||
const { user, roles } = useAuth();
|
||||
const isStaff = roles.includes("admin") || roles.includes("teacher");
|
||||
const qc = useQueryClient();
|
||||
const [activeThread, setActiveThread] = useState<string | null>(null);
|
||||
|
||||
const { data: threads } = useQuery({
|
||||
queryKey: ["threads", user?.id],
|
||||
queryFn: async () => {
|
||||
const { data: parts } = await supabase.from("thread_participants").select("thread_id").eq("user_id", user!.id);
|
||||
const ids = (parts ?? []).map((p) => p.thread_id);
|
||||
if (ids.length === 0) return [];
|
||||
const { data } = await supabase.from("message_threads").select("*").in("id", ids).order("updated_at", { ascending: false });
|
||||
return data ?? [];
|
||||
},
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
const ch = supabase.channel("messages-realtime").on("postgres_changes", { event: "*", schema: "public", table: "messages" }, () => {
|
||||
qc.invalidateQueries({ queryKey: ["thread-messages"] });
|
||||
}).subscribe();
|
||||
return () => { supabase.removeChannel(ch); };
|
||||
}, [user, qc]);
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-6xl">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-semibold">Messages</h1>
|
||||
<NewThreadDialog onCreated={(id) => setActiveThread(id)} isStaff={isStaff} />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 h-[70vh]">
|
||||
<div className="bg-card border rounded-lg overflow-auto divide-y">
|
||||
{(threads ?? []).map((t) => (
|
||||
<button key={t.id} onClick={() => setActiveThread(t.id)} className={`w-full text-left p-3 hover:bg-muted/50 ${activeThread === t.id ? "bg-muted" : ""}`}>
|
||||
<div className="font-medium text-sm">{t.subject ?? "(no subject)"}</div>
|
||||
<div className="text-xs text-muted-foreground">{new Date(t.updated_at).toLocaleDateString()}</div>
|
||||
</button>
|
||||
))}
|
||||
{threads?.length === 0 && <div className="p-6 text-sm text-muted-foreground text-center"><MessageSquare className="h-6 w-6 mx-auto mb-2" /> No threads yet</div>}
|
||||
</div>
|
||||
<div className="col-span-2 bg-card border rounded-lg flex flex-col">
|
||||
{activeThread ? <ThreadView threadId={activeThread} /> : <div className="flex-1 flex items-center justify-center text-sm text-muted-foreground">Select a thread</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThreadView({ threadId }: { threadId: string }) {
|
||||
const { user } = useAuth();
|
||||
const qc = useQueryClient();
|
||||
const [text, setText] = useState("");
|
||||
const endRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data: messages } = useQuery({
|
||||
queryKey: ["thread-messages", threadId],
|
||||
queryFn: async () => (await supabase.from("messages").select("*").eq("thread_id", threadId).order("created_at")).data ?? [],
|
||||
});
|
||||
|
||||
useEffect(() => { endRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages]);
|
||||
|
||||
const send = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!text.trim()) return;
|
||||
const { error } = await supabase.from("messages").insert({ thread_id: threadId, sender_id: user!.id, body: text.trim() });
|
||||
if (error) throw error;
|
||||
await supabase.from("message_threads").update({ updated_at: new Date().toISOString() }).eq("id", threadId);
|
||||
},
|
||||
onSuccess: () => { setText(""); qc.invalidateQueries({ queryKey: ["thread-messages", threadId] }); },
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex-1 overflow-auto p-4 space-y-2">
|
||||
{(messages ?? []).map((m) => (
|
||||
<div key={m.id} className={`flex ${m.sender_id === user?.id ? "justify-end" : ""}`}>
|
||||
<div className={`px-3 py-2 rounded-lg max-w-md text-sm ${m.sender_id === user?.id ? "bg-primary text-primary-foreground" : "bg-muted"}`}>
|
||||
{m.body}
|
||||
<div className="text-[10px] opacity-70 mt-1">{new Date(m.created_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
<form className="border-t p-3 flex gap-2" onSubmit={(e) => { e.preventDefault(); send.mutate(); }}>
|
||||
<Input value={text} onChange={(e) => setText(e.target.value)} placeholder="Type a message…" />
|
||||
<Button type="submit" disabled={!text.trim() || send.isPending}>Send</Button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NewThreadDialog({ onCreated, isStaff }: { onCreated: (id: string) => void; isStaff: boolean }) {
|
||||
const { user } = useAuth();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [subject, setSubject] = useState("");
|
||||
const [body, setBody] = useState("");
|
||||
const [recipientId, setRecipientId] = useState("");
|
||||
|
||||
const { data: recipients } = useQuery({
|
||||
queryKey: ["thread-recipients", isStaff],
|
||||
queryFn: async () => (await supabase.from("profiles").select("id, full_name, email").neq("id", user!.id).order("full_name")).data ?? [],
|
||||
enabled: open && !!user,
|
||||
});
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!recipientId) throw new Error("Pick a recipient");
|
||||
const { data: thread, error } = await supabase.from("message_threads").insert({ subject, created_by: user!.id }).select().single();
|
||||
if (error) throw error;
|
||||
const { error: pErr } = await supabase.from("thread_participants").insert([
|
||||
{ thread_id: thread.id, user_id: user!.id },
|
||||
{ thread_id: thread.id, user_id: recipientId },
|
||||
]);
|
||||
if (pErr) throw pErr;
|
||||
if (body.trim()) {
|
||||
await supabase.from("messages").insert({ thread_id: thread.id, sender_id: user!.id, body: body.trim() });
|
||||
}
|
||||
return thread.id;
|
||||
},
|
||||
onSuccess: (id) => { setOpen(false); setSubject(""); setBody(""); setRecipientId(""); onCreated(id); toast.success("Thread started"); },
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild><Button><Plus className="h-4 w-4 mr-1" /> New message</Button></DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>New conversation</DialogTitle></DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>Recipient</Label>
|
||||
<select className="w-full border rounded-md p-2 text-sm" value={recipientId} onChange={(e) => setRecipientId(e.target.value)}>
|
||||
<option value="">Choose…</option>
|
||||
{(recipients ?? []).map((r) => <option key={r.id} value={r.id}>{r.full_name || r.email}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div><Label>Subject</Label><Input value={subject} onChange={(e) => setSubject(e.target.value)} /></div>
|
||||
<div><Label>Message</Label><Textarea value={body} onChange={(e) => setBody(e.target.value)} rows={4} /></div>
|
||||
<Button className="w-full" onClick={() => create.mutate()} disabled={create.isPending}>Start conversation</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user