Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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