Skip to content
File

Blob: src/client/components/admin/temp-inbox-list.tsx

typescript159 lines
1import { useEffect, useState } from "react";
2import { ChevronLeft, ChevronRight, Clock, Filter, Loader2, Search } from "lucide-react";
3import { Link } from "react-router-dom";
4import { Button, Card, ErrorBanner, PageHeader } from "@/client/components/ui";
5import { useAdminSessionGuard } from "@/client/hooks/useAdminSessionGuard";
6import { listAdminTempInboxes, type AdminTempInbox } from "@/client/lib/api";
7import { fullDate } from "@/client/lib/time";
8import { ADMIN_TEMP_INBOX_PAGE_SIZE } from "@/shared/contracts";
9 
10interface TempInboxListProps {
11 onSessionError: (message?: string) => void;
12}
13 
14export function TempInboxList({ onSessionError }: TempInboxListProps) {
15 const [inboxes, setInboxes] = useState<AdminTempInbox[]>([]);
16 const [page, setPage] = useState(0);
17 const [total, setTotal] = useState(0);
18 const [pageSize, setPageSize] = useState(ADMIN_TEMP_INBOX_PAGE_SIZE);
19 const [hasEmails, setHasEmails] = useState(false);
20 const [loading, setLoading] = useState(false);
21 const [error, setError] = useState<string | null>(null);
22 const handleAdminError = useAdminSessionGuard(onSessionError);
23 
24 useEffect(() => {
25 let active = true;
26 
27 const load = async () => {
28 setLoading(true);
29 setError(null);
30 
31 try {
32 const results = await listAdminTempInboxes(page, hasEmails);
33 if (!active) return;
34 setInboxes(results.inboxes);
35 setTotal(results.total);
36 setPageSize(results.pageSize);
37 } catch (fetchError) {
38 if (!active) return;
39 handleAdminError(fetchError, setError);
40 } finally {
41 if (active) setLoading(false);
42 }
43 };
44 
45 void load();
46 return () => {
47 active = false;
48 };
49 }, [handleAdminError, hasEmails, page]);
50 
51 const totalPages = Math.max(1, Math.ceil(total / pageSize));
52 
53 const handlePageChange = (nextPage: number) => {
54 if (nextPage < 0 || nextPage >= totalPages || nextPage === page) return;
55 setPage(nextPage);
56 };
57 
58 const handleToggleFilter = () => {
59 setHasEmails((prev) => !prev);
60 setPage(0);
61 };
62 
63 return (
64 <Card>
65 <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
66 <PageHeader
67 caption="Temporary Inboxes"
68 captionIcon={<Clock className="h-3.5 w-3.5 text-zinc-500" />}
69 heading="Currently active mailboxes"
70 description="Browse live temporary inboxes, see how many emails they contain, and inspect them in read-only admin mode."
71 />
72 <div className="flex shrink-0 items-center gap-2">
73 <button
74 type="button"
75 onClick={handleToggleFilter}
76 className={`inline-flex items-center gap-1.5 whitespace-nowrap rounded-full border px-3 py-1 text-xs font-medium transition-colors ${
77 hasEmails
78 ? "border-accent-500/30 bg-accent-500/10 text-accent-300"
79 : "border-zinc-800/60 bg-zinc-900 text-zinc-500 hover:border-zinc-700/60 hover:text-zinc-400"
80 }`}
81 >
82 <Filter className="h-3 w-3" />
83 {hasEmails ? "With emails" : "All"}
84 </button>
85 <span className="whitespace-nowrap rounded-full border border-zinc-800/60 bg-zinc-900 px-3 py-1 text-xs text-zinc-400">
86 {total} active mailbox{total !== 1 ? "es" : ""}
87 </span>
88 </div>
89 </div>
90
91 {error ? <ErrorBanner className="mt-5">{error}</ErrorBanner> : null}
92
93 {loading ? (
94 <p className="mt-5 flex items-center gap-2 text-sm text-zinc-400">
95 <Loader2 className="h-3.5 w-3.5 animate-spin" />
96 Loading active temporary inboxes...
97 </p>
98 ) : null}
99 {!loading && !error && inboxes.length === 0 ? (
100 <p className="mt-5 text-sm text-zinc-400">
101 {hasEmails
102 ? "No active temporary inboxes with emails right now."
103 : "There are no active temporary inboxes right now."}
104 </p>
105 ) : null}
106
107 <div className="mt-5 space-y-3">
108 {inboxes.map((inbox) => (
109 <div key={inbox.address} className="rounded-lg border border-zinc-800/50 bg-zinc-800/30 p-4">
110 <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
111 <div className="min-w-0">
112 <strong className="block truncate text-sm font-semibold text-zinc-200">{inbox.address}</strong>
113 <p className="mt-1 text-xs text-zinc-400">
114 Created {fullDate(inbox.createdAt)} · Expires {inbox.expiresAt ? fullDate(inbox.expiresAt) : "never"}
115 </p>
116 <p className="mt-1 text-xs text-zinc-500">
117 {inbox.domain} · {inbox.ttlHours ? `${inbox.ttlHours}h lifetime` : "temporary"} ·{" "}
118 <span className="font-medium text-zinc-400">
119 {inbox.emailCount} email{inbox.emailCount !== 1 ? "s" : ""}
120 </span>
121 </p>
122 </div>
123
124 <Link
125 to={`/inbox/${encodeURIComponent(inbox.address)}?admin=1`}
126 className="inline-flex shrink-0 items-center justify-center gap-1.5 rounded-lg border border-accent-500/20 bg-accent-500/10 px-3 py-1.5 text-xs font-medium text-accent-300 transition-colors hover:bg-accent-500/20"
127 >
128 <Search className="h-3 w-3" />
129 Inspect mailbox
130 </Link>
131 </div>
132 </div>
133 ))}
134 </div>
135
136 {total > pageSize ? (
137 <div className="mt-5 flex flex-wrap items-center justify-between gap-3 border-t border-zinc-800/60 pt-4 text-sm text-zinc-400">
138 <span>
139 Page {page + 1} of {totalPages}
140 </span>
141 <div className="flex gap-2">
142 <Button
143 icon={<ChevronLeft className="h-3 w-3" />}
144 disabled={page === 0 || loading}
145 onClick={() => handlePageChange(page - 1)}
146 >
147 Previous
148 </Button>
149 <Button disabled={page >= totalPages - 1 || loading} onClick={() => handlePageChange(page + 1)}>
150 Next
151 <ChevronRight className="h-3 w-3" />
152 </Button>
153 </div>
154 </div>
155 ) : null}
156 </Card>
157 );
158}