From 913410dbf3e1baa18231575d8c2106230ee1618a Mon Sep 17 00:00:00 2001 From: Arnaud Date: Fri, 10 Jul 2026 07:57:17 +0200 Subject: [PATCH] feat: notifications inbox page The bell only ever showed the last 30; add a full paginated /notifications page, per-notification mark-read, and a shared notificationHref helper so the bell and the page route identically instead of duplicating the logic. Co-Authored-By: Claude Sonnet 5 --- apps/web/app/(app)/notifications/loading.tsx | 14 ++ apps/web/app/(app)/notifications/page.tsx | 13 ++ .../api/v1/notifications/[id]/read/route.ts | 17 ++ apps/web/app/api/v1/notifications/route.ts | 27 ++- .../notifications-page-content.tsx | 164 ++++++++++++++++++ .../components/social/notification-bell.tsx | 15 +- apps/web/lib/notification-links.ts | 15 ++ apps/web/messages/en.json | 6 + apps/web/messages/fr.json | 6 + 9 files changed, 265 insertions(+), 12 deletions(-) create mode 100644 apps/web/app/(app)/notifications/loading.tsx create mode 100644 apps/web/app/(app)/notifications/page.tsx create mode 100644 apps/web/app/api/v1/notifications/[id]/read/route.ts create mode 100644 apps/web/components/notifications/notifications-page-content.tsx create mode 100644 apps/web/lib/notification-links.ts diff --git a/apps/web/app/(app)/notifications/loading.tsx b/apps/web/app/(app)/notifications/loading.tsx new file mode 100644 index 0000000..048163d --- /dev/null +++ b/apps/web/app/(app)/notifications/loading.tsx @@ -0,0 +1,14 @@ +import { PageHeaderSkeleton, ListRowSkeleton } from "@/components/shared/skeletons"; + +export default function NotificationsLoading() { + return ( +
+ +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+ ); +} diff --git a/apps/web/app/(app)/notifications/page.tsx b/apps/web/app/(app)/notifications/page.tsx new file mode 100644 index 0000000..ea915f8 --- /dev/null +++ b/apps/web/app/(app)/notifications/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; +import { headers } from "next/headers"; +import { auth } from "@/lib/auth/server"; +import { NotificationsPageContent } from "@/components/notifications/notifications-page-content"; + +export const metadata: Metadata = {}; + +export default async function NotificationsPage() { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) return null; + + return ; +} diff --git a/apps/web/app/api/v1/notifications/[id]/read/route.ts b/apps/web/app/api/v1/notifications/[id]/read/route.ts new file mode 100644 index 0000000..9008420 --- /dev/null +++ b/apps/web/app/api/v1/notifications/[id]/read/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from "next/server"; +import { db, notifications, eq, and } from "@epicure/db"; +import { requireSession } from "@/lib/api-auth"; + +export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) { + const { session, response } = await requireSession(); + if (response) return response; + + const { id } = await params; + + await db + .update(notifications) + .set({ read: true }) + .where(and(eq(notifications.id, id), eq(notifications.userId, session!.user.id))); + + return NextResponse.json({ ok: true }); +} diff --git a/apps/web/app/api/v1/notifications/route.ts b/apps/web/app/api/v1/notifications/route.ts index 2aa0a24..5ccb621 100644 --- a/apps/web/app/api/v1/notifications/route.ts +++ b/apps/web/app/api/v1/notifications/route.ts @@ -1,12 +1,18 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { db, notifications, users, eq, and, desc, count } from "@epicure/db"; import { requireSession } from "@/lib/api-auth"; -export async function GET() { +export async function GET(req: NextRequest) { const { session, response } = await requireSession(); if (response) return response; - const [rows, unread] = await Promise.all([ + const { searchParams } = req.nextUrl; + const limitRaw = parseInt(searchParams.get("limit") ?? "20"); + const limit = Math.min(Number.isNaN(limitRaw) ? 20 : Math.max(1, limitRaw), 100); + const offsetRaw = parseInt(searchParams.get("offset") ?? "0"); + const offset = Number.isNaN(offsetRaw) ? 0 : Math.max(0, offsetRaw); + + const [rows, unread, totalRow] = await Promise.all([ db .select({ id: notifications.id, @@ -24,12 +30,23 @@ export async function GET() { .innerJoin(users, eq(notifications.actorId, users.id)) .where(eq(notifications.userId, session!.user.id)) .orderBy(desc(notifications.createdAt)) - .limit(30), + .limit(limit) + .offset(offset), db .select({ total: count() }) .from(notifications) .where(and(eq(notifications.userId, session!.user.id), eq(notifications.read, false))), + db + .select({ total: count() }) + .from(notifications) + .where(eq(notifications.userId, session!.user.id)), ]); - return NextResponse.json({ notifications: rows, unreadCount: unread[0]?.total ?? 0 }); + return NextResponse.json({ + notifications: rows, + unreadCount: unread[0]?.total ?? 0, + total: totalRow[0]?.total ?? 0, + limit, + offset, + }); } diff --git a/apps/web/components/notifications/notifications-page-content.tsx b/apps/web/components/notifications/notifications-page-content.tsx new file mode 100644 index 0000000..1427dbf --- /dev/null +++ b/apps/web/components/notifications/notifications-page-content.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import Link from "next/link"; +import { useTranslations } from "next-intl"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { notificationHref, type NotificationType } from "@/lib/notification-links"; + +const PAGE_SIZE = 20; + +type Notification = { + id: string; + type: NotificationType; + recipeId: string | null; + commentId: string | null; + read: boolean; + createdAt: string; + actorId: string; + actorName: string; + actorUsername: string | null; + actorAvatarUrl: string | null; +}; + +type NotificationsResponse = { + notifications: Notification[]; + unreadCount: number; + total: number; + limit: number; + offset: number; +}; + +function timeAgo(dateStr: string, t: ReturnType) { + const diff = Date.now() - new Date(dateStr).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return t("justNow"); + if (mins < 60) return t("minutesAgo", { mins }); + const hours = Math.floor(mins / 60); + if (hours < 24) return t("hoursAgo", { hours }); + return t("daysAgo", { days: Math.floor(hours / 24) }); +} + +export function NotificationsPageContent() { + const t = useTranslations("notifications"); + const tSocial = useTranslations("social"); + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [unreadCount, setUnreadCount] = useState(0); + const [offset, setOffset] = useState(0); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [error, setError] = useState(false); + + const fetchPage = useCallback(async (off: number, append: boolean) => { + if (append) setLoadingMore(true); + else setLoading(true); + setError(false); + try { + const res = await fetch(`/api/v1/notifications?limit=${PAGE_SIZE}&offset=${off}`); + if (!res.ok) throw new Error("Request failed"); + const json = (await res.json()) as NotificationsResponse; + setItems((prev) => (append ? [...prev, ...json.notifications] : json.notifications)); + setTotal(json.total); + setUnreadCount(json.unreadCount); + setOffset(off + json.notifications.length); + } catch { + setError(true); + } finally { + setLoading(false); + setLoadingMore(false); + } + }, []); + + useEffect(() => { + void fetchPage(0, false); + }, [fetchPage]); + + async function markRead(id: string) { + setItems((prev) => prev.map((n) => (n.id === id ? { ...n, read: true } : n))); + setUnreadCount((prev) => { + const n = items.find((x) => x.id === id); + return n && !n.read ? Math.max(0, prev - 1) : prev; + }); + await fetch(`/api/v1/notifications/${id}/read`, { method: "POST" }); + } + + async function markAllRead() { + setUnreadCount(0); + setItems((prev) => prev.map((n) => ({ ...n, read: true }))); + await fetch("/api/v1/notifications/read", { method: "POST" }); + } + + const hasMore = items.length < total; + + return ( +
+
+

{t("title")}

+ {unreadCount > 0 && ( + + )} +
+ + {loading ? ( +

{t("loading")}

+ ) : error && items.length === 0 ? ( +
+

{t("loadFailed")}

+ +
+ ) : items.length === 0 ? ( +

{t("empty")}

+ ) : ( +
+ {items.map((n) => ( + { if (!n.read) void markRead(n.id); }} + className={cn( + "flex items-start gap-3 p-4 hover:bg-accent transition-colors", + !n.read && "bg-accent/40" + )} + > + + + {n.actorName.slice(0, 2).toUpperCase()} + +
+

+ {t(n.type, { name: n.actorName })} +

+

{timeAgo(n.createdAt, tSocial)}

+
+ {!n.read && } + + ))} +
+ )} + + {error && items.length > 0 && ( +

{t("loadFailed")}

+ )} + + {(hasMore || (error && items.length > 0)) && ( +
+ +
+ )} +
+ ); +} diff --git a/apps/web/components/social/notification-bell.tsx b/apps/web/components/social/notification-bell.tsx index 06fcfec..7f3af7b 100644 --- a/apps/web/components/social/notification-bell.tsx +++ b/apps/web/components/social/notification-bell.tsx @@ -13,10 +13,11 @@ import { } from "@/components/ui/dropdown-menu"; import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; +import { notificationHref, type NotificationType } from "@/lib/notification-links"; type Notification = { id: string; - type: "follow" | "comment" | "reply" | "reaction" | "rating" | "mention"; + type: NotificationType; recipeId: string | null; commentId: string | null; read: boolean; @@ -26,12 +27,6 @@ type Notification = { actorUsername: string | null; }; -function notificationHref(n: Notification): string { - if (n.type === "follow") return n.actorUsername ? `/u/${n.actorUsername}` : "#"; - if (n.recipeId) return `/recipes/${n.recipeId}`; - return "#"; -} - export function NotificationBell() { const t = useTranslations("notifications"); const [notifications, setNotifications] = useState([]); @@ -104,6 +99,12 @@ export function NotificationBell() { ))} + } + className="justify-center text-sm text-muted-foreground hover:text-foreground" + > + {t("viewAll")} + ); diff --git a/apps/web/lib/notification-links.ts b/apps/web/lib/notification-links.ts new file mode 100644 index 0000000..064fdef --- /dev/null +++ b/apps/web/lib/notification-links.ts @@ -0,0 +1,15 @@ +export type NotificationType = "follow" | "comment" | "reply" | "reaction" | "rating" | "mention"; + +export type NotificationLinkable = { + type: NotificationType; + recipeId: string | null; + actorUsername: string | null; +}; + +/** Where clicking a notification should navigate to. Shared by the bell dropdown + * and the full notifications page so the two never drift apart. */ +export function notificationHref(n: NotificationLinkable): string { + if (n.type === "follow") return n.actorUsername ? `/u/${n.actorUsername}` : "#"; + if (n.recipeId) return `/recipes/${n.recipeId}`; + return "#"; +} diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index df91f65..e7a9172 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -24,6 +24,12 @@ "title": "Notifications", "empty": "No notifications yet.", "markAllRead": "Mark all as read", + "viewAll": "View all", + "loading": "Loading…", + "loadMore": "Load more", + "loadFailed": "Failed to load notifications. Check your connection and try again.", + "retry": "Retry", + "unread": "Unread", "follow": "{name} followed you", "comment": "{name} commented on your recipe", "reply": "{name} replied to your comment", diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index 19888cb..81a10d3 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -24,6 +24,12 @@ "title": "Notifications", "empty": "Aucune notification pour l'instant.", "markAllRead": "Tout marquer comme lu", + "viewAll": "Tout voir", + "loading": "Chargement…", + "loadMore": "Charger plus", + "loadFailed": "Échec du chargement des notifications. Vérifiez votre connexion et réessayez.", + "retry": "Réessayer", + "unread": "Non lu", "follow": "{name} vous suit désormais", "comment": "{name} a commenté votre recette", "reply": "{name} a répondu à votre commentaire",