Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
co-authored by
renee-png
parent
41f2299fe2
commit
c8f250715f
@@ -0,0 +1,299 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { ProtectedLayout } from "@/components/protected-layout";
|
||||
import { PageContainer, PageHeader } from "@/components/app-shell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Loader2, Inbox as InboxIcon, RefreshCcw, Search, Mail, MailOpen, Archive, ArchiveRestore, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
export const Route = createFileRoute("/inbox/")({
|
||||
component: InboxPage,
|
||||
});
|
||||
|
||||
interface IncomingEmail {
|
||||
id: string;
|
||||
received_at: string;
|
||||
from_address: string | null;
|
||||
from_name: string | null;
|
||||
to_addresses: string[];
|
||||
subject: string | null;
|
||||
snippet: string | null;
|
||||
body_text: string | null;
|
||||
body_html: string | null;
|
||||
is_read: boolean;
|
||||
is_archived: boolean;
|
||||
has_attachments: boolean;
|
||||
attachment_count: number;
|
||||
}
|
||||
|
||||
function InboxPage() {
|
||||
const { isAdmin } = useAuth();
|
||||
const [emails, setEmails] = useState<IncomingEmail[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [polling, setPolling] = useState(false);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
let query = supabase
|
||||
.from("incoming_emails")
|
||||
.select("id, received_at, from_address, from_name, to_addresses, subject, snippet, body_text, body_html, is_read, is_archived, has_attachments, attachment_count")
|
||||
.order("received_at", { ascending: false })
|
||||
.limit(200);
|
||||
query = showArchived ? query.eq("is_archived", true) : query.eq("is_archived", false);
|
||||
const { data, error } = await query;
|
||||
if (error) {
|
||||
toast.error("Failed to load inbox", { description: error.message });
|
||||
} else {
|
||||
setEmails((data as IncomingEmail[]) ?? []);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [showArchived]);
|
||||
|
||||
const pollNow = async () => {
|
||||
setPolling(true);
|
||||
try {
|
||||
const res = await fetch("/hooks/poll-imap", { method: "POST" });
|
||||
const body = await res.json();
|
||||
if (!res.ok || body.error) {
|
||||
toast.error("Poll failed", { description: body.error });
|
||||
} else {
|
||||
toast.success(`Imported ${body.imported ?? 0} new email(s)`);
|
||||
load();
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error("Poll failed", {
|
||||
description: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
} finally {
|
||||
setPolling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const markRead = async (id: string, isRead: boolean) => {
|
||||
await supabase.from("incoming_emails").update({ is_read: isRead }).eq("id", id);
|
||||
setEmails((prev) =>
|
||||
prev.map((e) => (e.id === id ? { ...e, is_read: isRead } : e)),
|
||||
);
|
||||
};
|
||||
|
||||
const toggleArchive = async (id: string, archive: boolean) => {
|
||||
await supabase.from("incoming_emails").update({ is_archived: archive }).eq("id", id);
|
||||
setEmails((prev) => prev.filter((e) => e.id !== id));
|
||||
if (selectedId === id) setSelectedId(null);
|
||||
toast.success(archive ? "Archived" : "Restored");
|
||||
};
|
||||
|
||||
const deleteEmail = async (id: string) => {
|
||||
if (!confirm("Delete this email permanently?")) return;
|
||||
const { error } = await supabase.from("incoming_emails").delete().eq("id", id);
|
||||
if (error) {
|
||||
toast.error("Delete failed", { description: error.message });
|
||||
return;
|
||||
}
|
||||
setEmails((prev) => prev.filter((e) => e.id !== id));
|
||||
if (selectedId === id) setSelectedId(null);
|
||||
};
|
||||
|
||||
const filtered = emails.filter((e) => {
|
||||
if (!search.trim()) return true;
|
||||
const q = search.toLowerCase();
|
||||
return (
|
||||
(e.subject ?? "").toLowerCase().includes(q) ||
|
||||
(e.from_address ?? "").toLowerCase().includes(q) ||
|
||||
(e.from_name ?? "").toLowerCase().includes(q) ||
|
||||
(e.snippet ?? "").toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
const selected = emails.find((e) => e.id === selectedId) ?? null;
|
||||
const unreadCount = emails.filter((e) => !e.is_read).length;
|
||||
|
||||
return (
|
||||
<ProtectedLayout>
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Inbox"
|
||||
description={`Incoming emails received via IMAP. ${unreadCount} unread.`}
|
||||
actions={
|
||||
<Button onClick={pollNow} disabled={polling} variant="outline" size="sm">
|
||||
{polling ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<RefreshCcw className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Check for new mail
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by subject, sender..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant={showArchived ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowArchived((v) => !v);
|
||||
setSelectedId(null);
|
||||
}}
|
||||
>
|
||||
{showArchived ? "Showing archived" : "Show archived"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="py-12 text-center text-sm text-muted-foreground">
|
||||
<InboxIcon className="h-8 w-8 mx-auto mb-3 opacity-50" />
|
||||
{showArchived ? "No archived emails." : "No emails yet. "}
|
||||
{!showArchived && (
|
||||
<>Configure IMAP in <a className="underline" href="/settings/imap">Settings → Email (IMAP)</a> and click "Check for new mail".</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]">
|
||||
<Card className="border-border/60 overflow-hidden">
|
||||
<div className="divide-y max-h-[70vh] overflow-y-auto">
|
||||
{filtered.map((email) => (
|
||||
<button
|
||||
key={email.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedId(email.id);
|
||||
if (!email.is_read) markRead(email.id, true);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-3 hover:bg-muted/50 transition-colors ${
|
||||
selectedId === email.id ? "bg-muted" : ""
|
||||
} ${!email.is_read ? "font-medium" : ""}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-sm">
|
||||
{email.from_name || email.from_address || "Unknown sender"}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground whitespace-nowrap">
|
||||
{new Date(email.received_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm truncate mt-0.5">
|
||||
{email.subject || "(no subject)"}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate mt-0.5">
|
||||
{email.snippet || "(empty body)"}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
{!email.is_read && (
|
||||
<Badge variant="default" className="h-4 text-[9px] px-1.5">NEW</Badge>
|
||||
)}
|
||||
{email.has_attachments && (
|
||||
<Badge variant="secondary" className="h-4 text-[9px] px-1.5">
|
||||
📎 {email.attachment_count}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/60">
|
||||
{selected ? (
|
||||
<CardContent className="p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-serif text-lg leading-tight">
|
||||
{selected.subject || "(no subject)"}
|
||||
</h2>
|
||||
<div className="text-sm text-muted-foreground mt-1">
|
||||
From: <span className="text-foreground">
|
||||
{selected.from_name
|
||||
? `${selected.from_name} <${selected.from_address}>`
|
||||
: selected.from_address}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
To: {selected.to_addresses.join(", ")}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{new Date(selected.received_at).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => markRead(selected.id, !selected.is_read)}
|
||||
title={selected.is_read ? "Mark unread" : "Mark read"}
|
||||
>
|
||||
{selected.is_read ? <Mail className="h-4 w-4" /> : <MailOpen className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => toggleArchive(selected.id, !selected.is_archived)}
|
||||
title={selected.is_archived ? "Unarchive" : "Archive"}
|
||||
>
|
||||
{selected.is_archived ? <ArchiveRestore className="h-4 w-4" /> : <Archive className="h-4 w-4" />}
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => deleteEmail(selected.id)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t pt-3">
|
||||
{selected.body_html ? (
|
||||
<iframe
|
||||
title="Email body"
|
||||
srcDoc={selected.body_html}
|
||||
sandbox=""
|
||||
className="w-full min-h-[400px] border rounded bg-white"
|
||||
/>
|
||||
) : (
|
||||
<pre className="text-sm whitespace-pre-wrap font-sans">
|
||||
{selected.body_text || "(empty)"}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
) : (
|
||||
<CardContent className="py-16 text-center text-sm text-muted-foreground">
|
||||
Select an email to read.
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</PageContainer>
|
||||
</ProtectedLayout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user