diff --git a/src/routes/_authenticated/messages.tsx b/src/routes/_authenticated/messages.tsx new file mode 100644 index 0000000..904b8f6 --- /dev/null +++ b/src/routes/_authenticated/messages.tsx @@ -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(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 ( +
+
+

Messages

+ setActiveThread(id)} isStaff={isStaff} /> +
+
+
+ {(threads ?? []).map((t) => ( + + ))} + {threads?.length === 0 &&
No threads yet
} +
+
+ {activeThread ? :
Select a thread
} +
+
+
+ ); +} + +function ThreadView({ threadId }: { threadId: string }) { + const { user } = useAuth(); + const qc = useQueryClient(); + const [text, setText] = useState(""); + const endRef = useRef(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 ( + <> +
+ {(messages ?? []).map((m) => ( +
+
+ {m.body} +
{new Date(m.created_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
+
+
+ ))} +
+
+
{ e.preventDefault(); send.mutate(); }}> + setText(e.target.value)} placeholder="Type a message…" /> + +
+ + ); +} + +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 ( + + + + New conversation +
+
+ + +
+
setSubject(e.target.value)} />
+