Skip to content

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

146 lines6,065 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all1/**
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";
Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings11import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select";
Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all12import { cn } from "../lib/cn";
Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings13import { REASON_FILTERS, inboxReason, inboxTab, inboxView, markFromForm, severityOf, tabCount } from "../lib/inbox";
Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all14import { page } from "../lib/meta";
15import { inbox } from "../lib/services.server";
16import { assertSameOrigin, requireUser } from "../lib/session.server";
17
18const PAGE_ITEMS = 50;
19
20export function meta(args: Route.MetaArgs) {
21 return page(args, { title: "Inbox · g1t" });
22}
23
24export async function loader({ request, context }: Route.LoaderArgs) {
25 const user = requireUser(context, request);
26 const url = new URL(request.url);
27 const tab = inboxTab(url.searchParams.get("tab"));
28 const view = inboxView(url.searchParams.get("view"));
Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings29 const reason = inboxReason(url.searchParams.get("reason"));
Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all30 const before = url.searchParams.get("before");
31 const [list, counts] = await Promise.all([
Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings32 inbox.list(user, { view, severity: severityOf(tab), reason, before, limit: PAGE_ITEMS }).catch(() => null),
Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all33 inbox.counts(user.username).catch(() => null),
34 ]);
Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings35 return { tab, view, reason, before, items: list?.items ?? null, next: list?.next ?? null, counts };
Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all36}
37
38export async function action({ request, context }: Route.ActionArgs) {
39 assertSameOrigin(request);
40 const user = requireUser(context, request);
41 const mark = markFromForm(await request.formData(), Date.now());
42 if (!mark) return data({ error: "Nothing to do." }, { status: 400 });
43 try {
44 await inbox.mark(user.username, mark);
45 return { error: null };
46 } catch {
47 return data({ error: "That did not save. Try again in a moment." }, { status: 503 });
48 }
49}
50
51const VIEWS = [
52 { view: "inbox", label: "Inbox", icon: <InboxIcon size={14} /> },
53 { view: "saved", label: "Saved", icon: <Bookmark size={14} /> },
54 { view: "done", label: "Done", icon: <Check size={14} /> },
55] as const;
56
57export default function InboxPage({ loaderData }: Route.ComponentProps) {
Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings58 const { tab, view, reason, before, items, next, counts } = loaderData;
Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all59 const navigate = useNavigate();
60 const address = (changes: Record<string, string | null>) => {
61 const params = new URLSearchParams();
Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings62 const merged = { tab: tab === "all" ? null : tab, view: view === "inbox" ? null : view, reason, ...changes };
Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all63 for (const [name, value] of Object.entries(merged)) if (value) params.set(name, value);
64 const query = params.toString();
65 return query ? `/inbox?${query}` : "/inbox";
66 };
67
68 return (
69 <main className="mx-auto max-w-3xl px-4 py-8 sm:px-6">
70 <div className="flex flex-wrap items-center justify-between gap-3">
71 <div>
72 <h1 className="text-2xl font-semibold tracking-tight">Inbox</h1>
Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings73 <p className="mt-1 text-sm text-muted">What needs you, and what you follow. What is waiting on you comes first.</p>
Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all74 </div>
75 {view === "inbox" && <MarkAllRead tab={tab} disabled={tabCount(counts, tab) === 0} />}
76 </div>
77
78 <nav aria-label="Inbox views" className="mt-6 flex gap-1 border-b border-line">
79 {VIEWS.map((entry) => (
80 <Link
81 key={entry.view}
82 to={address({ view: entry.view === "inbox" ? null : entry.view, before: null })}
83 aria-current={view === entry.view ? "page" : undefined}
84 className={cn(
85 "-mb-px flex items-center gap-2 border-b-2 px-3 pb-3 text-sm transition-colors",
86 view === entry.view ? "border-accent font-medium text-fg" : "border-transparent text-muted hover:text-fg",
87 )}
88 >
89 {entry.icon}
90 {entry.label}
91 </Link>
92 ))}
93 </nav>
94
Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings95 <div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center">
96 <div className="min-w-0 grow">
97 <InboxTabs tab={tab} counts={view === "inbox" ? counts : null} onChange={(next) => navigate(address({ tab: next === "all" ? null : next, before: null }))} />
98 </div>
99 {/* Why you were told: a review asked of you, a mention, what you watch. */}
100 <Select value={reason ?? "any"} onValueChange={(value) => navigate(address({ reason: value === "any" ? null : value, before: null }))}>
101 <SelectTrigger size="sm" aria-label="Reason" className="sm:w-44">
102 <SelectValue />
103 </SelectTrigger>
104 <SelectContent align="end">
105 {REASON_FILTERS.map((entry) => (
106 <SelectItem key={entry.reason ?? "any"} value={entry.reason ?? "any"}>
107 {entry.label}
108 </SelectItem>
109 ))}
110 </SelectContent>
111 </Select>
Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all112 </div>
113
114 <div className="mt-4">
115 {items == null ? (
116 <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>
117 ) : items.length === 0 ? (
118 <InboxEmpty tab={tab} view={view} />
119 ) : (
120 <ul className="space-y-2">
121 {items.map((item) => (
122 <InboxCard key={item.id} item={item} />
123 ))}
124 </ul>
125 )}
126 </div>
127
128 {(before || next) && (
129 <div className="mt-6 flex justify-between text-sm">
130 {before ? (
131 <Link to={address({ before: null })} className="text-muted hover:text-fg">
132 Newest
133 </Link>
134 ) : (
135 <span />
136 )}
137 {next && (
138 <Link to={address({ before: next })} className="font-medium text-accent hover:underline">
139 Older
140 </Link>
141 )}
142 </div>
143 )}
144 </main>
145 );
146}