Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
507 changes: 507 additions & 0 deletions NOTIFICATION_SYSTEM_PLAN.md

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions src/components/app-navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ export function AppNavbar() {
</span>
</div>

<div className="ml-auto">
<div className="ml-auto flex items-center gap-1">
<SidebarMenuButton
onClick={toggleSidebar}
aria-label="Toggle sidebar"
Expand All @@ -168,7 +168,7 @@ export function AppNavbar() {

<div
className={cn(
"absolute inset-0 flex items-center justify-end w-full overflow-hidden",
"absolute inset-0 flex items-center justify-end gap-1 w-full overflow-hidden",
"transition-opacity duration-200 ease-in-out motion-safe",
"opacity-0 group-data-[state=collapsed]:opacity-100",
"group-data-[state=collapsed]:pointer-events-auto pointer-events-none",
Expand Down Expand Up @@ -202,6 +202,7 @@ export function AppNavbar() {
<SidebarMenuButton
asChild
isActive={isActive}
tooltip={item.label}
className={cn(
"relative gap-3.5 h-11.4 group-data-[state=collapsed]:h-10 my-1.5 group-data-[state=collapsed]:my-[0.425rem]",
"overflow-visible transition-all",
Expand Down
2 changes: 1 addition & 1 deletion src/components/classes/edit/class-form-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export function ClassEditShell({
>
<PageLayout>
<PageLayoutHeader border="scroll">
<PageLayoutHeaderContent showBackButton>
<PageLayoutHeaderContent showBackButton hideInbox>
<PageLayoutHeaderTitle>
{isEditing ? "Edit Class" : "Create Class"}
</PageLayoutHeaderTitle>
Expand Down
2 changes: 1 addition & 1 deletion src/components/classes/list/components/term-select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export function TermSelect({
variant="outline"
endIcon=<ChevronDown />
className={cn(
"min-w-45 max-w-80 shrink justify-between gap-2",
"min-w-45 max-w-80 h-9 shrink justify-between gap-2",
className,
)}
>
Expand Down
304 changes: 304 additions & 0 deletions src/components/notifications/notification-inbox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,304 @@
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import {
Archive,
Check,
Inbox,
ListFilter,
MoreHorizontal,
} from "lucide-react";
import { Button } from "@/components/primitives/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ScrollArea } from "@/components/ui/scroll-area";
import { clientApi } from "@/trpc/client";
import { NotificationItem } from "./notification-item";

type Filter = "all" | "unread" | "archived";

function getQueryParams(filter: Filter) {
switch (filter) {
case "all":
return { archived: false } as const;
case "unread":
return { read: false, archived: false } as const;
case "archived":
return { archived: true } as const;
}
}

const filterLabels: Record<Filter, string> = {
all: "Unread & read",
unread: "Unread",
archived: "Archived",
};

const emptyMessages: Record<Filter, string> = {
all: "You're all caught up",
unread: "No unread notifications",
archived: "No archived notifications",
};

export function NotificationInbox() {
const router = useRouter();
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState<Filter>("all");
const utils = clientApi.useUtils();

const { data: unreadCount = 0 } = clientApi.notification.unreadCount.useQuery(
undefined,
{
refetchInterval: 30_000,
},
);

const { data, isLoading } = clientApi.notification.list.useQuery(
{ limit: 20, ...getQueryParams(filter) },
{ enabled: open },
);

const markAsRead = clientApi.notification.markAsRead.useMutation({
onSuccess: () => {
void utils.notification.unreadCount.invalidate();
void utils.notification.list.invalidate();
},
});

const markAsUnread = clientApi.notification.markAsUnread.useMutation({
onSuccess: () => {
void utils.notification.unreadCount.invalidate();
void utils.notification.list.invalidate();
},
});

const markAllAsRead = clientApi.notification.markAllAsRead.useMutation({
onSuccess: () => {
void utils.notification.unreadCount.invalidate();
void utils.notification.list.invalidate();
},
});

const archive = clientApi.notification.archive.useMutation({
onSuccess: () => {
void utils.notification.unreadCount.invalidate();
void utils.notification.list.invalidate();
},
});

const unarchive = clientApi.notification.unarchive.useMutation({
onSuccess: () => {
void utils.notification.unreadCount.invalidate();
void utils.notification.list.invalidate();
},
});

const archiveAll = clientApi.notification.archiveAll.useMutation({
onSuccess: () => {
void utils.notification.unreadCount.invalidate();
void utils.notification.list.invalidate();
},
});

const handleNotificationClick = (
notificationId: string,
linkUrl?: string | null,
) => {
markAsRead.mutate({ notificationId });
if (linkUrl) {
router.push(linkUrl as any);
}
setOpen(false);
};

const handleArchive = (notificationId: string) => {
archive.mutate({ notificationId });
};

const handleUnarchive = (notificationId: string) => {
unarchive.mutate({ notificationId });
};

const handleToggleRead = (notificationId: string) => {
const item = items.find((n) => n.id === notificationId);
if (item?.read) {
markAsUnread.mutate({ notificationId });
} else {
markAsRead.mutate({ notificationId });
}
};

const items = data?.items ?? [];

// Group into time buckets
const now = new Date();
const startOfToday = new Date(now);
startOfToday.setHours(0, 0, 0, 0);

const sevenDaysAgo = new Date(startOfToday);
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);

const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);

const groups: { label: string; items: typeof items }[] = [];
const todayItems = items.filter((n) => new Date(n.createdAt) >= startOfToday);
const weekItems = items.filter((n) => {
const d = new Date(n.createdAt);
return d < startOfToday && d >= sevenDaysAgo;
});
const monthItems = items.filter((n) => {
const d = new Date(n.createdAt);
return d < sevenDaysAgo && d >= startOfMonth;
});
const olderItems = items.filter((n) => new Date(n.createdAt) < startOfMonth);

if (todayItems.length > 0) groups.push({ label: "Today", items: todayItems });
if (weekItems.length > 0)
groups.push({ label: "This Week", items: weekItems });
if (monthItems.length > 0)
groups.push({ label: "This Month", items: monthItems });
if (olderItems.length > 0) groups.push({ label: "Older", items: olderItems });

const isArchivedView = filter === "archived";
const hasUnread = items.some((n) => !n.read);
const hasItems = items.length > 0;

return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="icon"
className="relative"
startIcon={<Inbox className="size-5" />}
>
{unreadCount > 0 && (
<span className="absolute top-2 right-2 size-2 rounded-full bg-red-500" />
)}
Notifications
</Button>
</PopoverTrigger>
<PopoverContent
align="end"
side="bottom"
className="w-96 p-0 overflow-hidden rounded-lg"
sideOffset={8}
onOpenAutoFocus={(e) => e.preventDefault()}
>
{/* Header */}
<div className="flex items-center justify-between border-b p-1">
<h3 className="pl-3 text-sm font-semibold">Notifications</h3>
<div className="flex items-center gap-0.5">
{/* Filter dropdown */}
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
tooltip="Filter"
startIcon={<ListFilter />}
></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel>Filter</DropdownMenuLabel>
<DropdownMenuSeparator />
{(["all", "unread", "archived"] as const).map((f) => (
<DropdownMenuCheckboxItem
key={f}
checked={filter === f}
onCheckedChange={() => setFilter(f)}
>
{filterLabels[f]}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>

{/* More actions dropdown */}
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
tooltip="More actions"
startIcon={<MoreHorizontal />}
></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
disabled={
markAllAsRead.isPending || isArchivedView || !hasUnread
}
onSelect={() => markAllAsRead.mutate()}
>
<Check className="mr-2 size-4" />
Mark all as read
</DropdownMenuItem>
<DropdownMenuItem
disabled={archiveAll.isPending || isArchivedView || !hasItems}
onSelect={() => archiveAll.mutate()}
>
<Archive className="mr-2 size-4" />
Archive all
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>

{/* Notification list */}
<ScrollArea className="max-h-96">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<p className="text-sm text-muted-foreground">Loading...</p>
</div>
) : items.length === 0 ? (
<div className="flex items-center justify-center py-8">
<p className="text-sm text-muted-foreground">
{emptyMessages[filter]}
</p>
</div>
) : (
<div>
{groups.map((group) => (
<div key={group.label}>
{groups.length > 1 && (
<p className="px-4 py-1.5 text-xs font-medium text-muted-foreground">
{group.label}
</p>
)}
<div className="divide-y">
{group.items.map((n) => (
<NotificationItem
key={n.id}
notification={n}
onClick={() => handleNotificationClick(n.id, n.linkUrl)}
onArchive={handleArchive}
onUnarchive={handleUnarchive}
onToggleRead={handleToggleRead}
isArchivedView={isArchivedView}
/>
))}
</div>
</div>
))}
</div>
)}
</ScrollArea>
</PopoverContent>
</Popover>
);
}
Loading