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