Terminal
This commit is contained in:
@@ -1,166 +0,0 @@
|
|||||||
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