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