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.

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