Skip to content

g1t/apps/web/app/routes/inbox.tsx

129 lines5,091 bytesCodeBlame
1/**
2 * The inbox, in full: the same tabs as the panel in the top bar, with what
3 * was saved and what is done, a page at a time. Every inbox form posts
4 * here, the panel's included.
5 */
6import { Bookmark, Check, Inbox as InboxIcon } from "lucide-react";
7import { Link, data, useNavigate } from "react-router";
8
9import type { Route } from "./+types/inbox";
10import { InboxCard, InboxEmpty, InboxTabs, MarkAllRead } from "../components/inbox";
11import { cn } from "../lib/cn";
12import { inboxTab, inboxView, markFromForm, severityOf, tabCount } from "../lib/inbox";
13import { page } from "../lib/meta";
14import { inbox } from "../lib/services.server";
15import { assertSameOrigin, requireUser } from "../lib/session.server";
16
17const PAGE_ITEMS = 50;
18
19export function meta(args: Route.MetaArgs) {
20 return page(args, { title: "Inbox · g1t" });
21}
22
23export async function loader({ request, context }: Route.LoaderArgs) {
24 const user = requireUser(context, request);
25 const url = new URL(request.url);
26 const tab = inboxTab(url.searchParams.get("tab"));
27 const view = inboxView(url.searchParams.get("view"));
28 const before = url.searchParams.get("before");
29 const [list, counts] = await Promise.all([
30 inbox.list(user, { view, severity: severityOf(tab), before, limit: PAGE_ITEMS }).catch(() => null),
31 inbox.counts(user.username).catch(() => null),
32 ]);
33 return { tab, view, before, items: list?.items ?? null, next: list?.next ?? null, counts };
34}
35
36export async function action({ request, context }: Route.ActionArgs) {
37 assertSameOrigin(request);
38 const user = requireUser(context, request);
39 const mark = markFromForm(await request.formData(), Date.now());
40 if (!mark) return data({ error: "Nothing to do." }, { status: 400 });
41 try {
42 await inbox.mark(user.username, mark);
43 return { error: null };
44 } catch {
45 return data({ error: "That did not save. Try again in a moment." }, { status: 503 });
46 }
47}
48
49const VIEWS = [
50 { view: "inbox", label: "Inbox", icon: <InboxIcon size={14} /> },
51 { view: "saved", label: "Saved", icon: <Bookmark size={14} /> },
52 { view: "done", label: "Done", icon: <Check size={14} /> },
53] as const;
54
55export default function InboxPage({ loaderData }: Route.ComponentProps) {
56 const { tab, view, before, items, next, counts } = loaderData;
57 const navigate = useNavigate();
58 const address = (changes: Record<string, string | null>) => {
59 const params = new URLSearchParams();
60 const merged = { tab: tab === "all" ? null : tab, view: view === "inbox" ? null : view, ...changes };
61 for (const [name, value] of Object.entries(merged)) if (value) params.set(name, value);
62 const query = params.toString();
63 return query ? `/inbox?${query}` : "/inbox";
64 };
65
66 return (
67 <main className="mx-auto max-w-3xl px-4 py-8 sm:px-6">
68 <div className="flex flex-wrap items-center justify-between gap-3">
69 <div>
70 <h1 className="text-2xl font-semibold tracking-tight">Inbox</h1>
71 <p className="mt-1 text-sm text-muted">What needs you, and what you follow. What an agent is waiting on comes first.</p>
72 </div>
73 {view === "inbox" && <MarkAllRead tab={tab} disabled={tabCount(counts, tab) === 0} />}
74 </div>
75
76 <nav aria-label="Inbox views" className="mt-6 flex gap-1 border-b border-line">
77 {VIEWS.map((entry) => (
78 <Link
79 key={entry.view}
80 to={address({ view: entry.view === "inbox" ? null : entry.view, before: null })}
81 aria-current={view === entry.view ? "page" : undefined}
82 className={cn(
83 "-mb-px flex items-center gap-2 border-b-2 px-3 pb-3 text-sm transition-colors",
84 view === entry.view ? "border-accent font-medium text-fg" : "border-transparent text-muted hover:text-fg",
85 )}
86 >
87 {entry.icon}
88 {entry.label}
89 </Link>
90 ))}
91 </nav>
92
93 <div className="mt-4">
94 <InboxTabs tab={tab} counts={view === "inbox" ? counts : null} onChange={(next) => navigate(address({ tab: next === "all" ? null : next, before: null }))} />
95 </div>
96
97 <div className="mt-4">
98 {items == null ? (
99 <p className="rounded-lg border border-line px-4 py-6 text-sm text-muted">The inbox could not be loaded. Try again in a moment.</p>
100 ) : items.length === 0 ? (
101 <InboxEmpty tab={tab} view={view} />
102 ) : (
103 <ul className="space-y-2">
104 {items.map((item) => (
105 <InboxCard key={item.id} item={item} />
106 ))}
107 </ul>
108 )}
109 </div>
110
111 {(before || next) && (
112 <div className="mt-6 flex justify-between text-sm">
113 {before ? (
114 <Link to={address({ before: null })} className="text-muted hover:text-fg">
115 Newest
116 </Link>
117 ) : (
118 <span />
119 )}
120 {next && (
121 <Link to={address({ before: next })} className="font-medium text-accent hover:underline">
122 Older
123 </Link>
124 )}
125 </div>
126 )}
127 </main>
128 );
129}