From 3779db838ff3024fe605ff860758cb98528beb6f Mon Sep 17 00:00:00 2001 From: pixelsama Date: Sun, 23 Nov 2025 17:32:44 +0800 Subject: [PATCH 01/15] feat: add filters to notifications page --- app/api/notifications/route.ts | 2 + app/notifications/page.tsx | 114 +++++++++++++++++++++++++++++-- lib/db/notification-center.ts | 10 +++ lib/types/notification-center.ts | 1 + messages/en-US.json | 21 ++++++ messages/zh-CN.json | 21 ++++++ 6 files changed, 165 insertions(+), 4 deletions(-) diff --git a/app/api/notifications/route.ts b/app/api/notifications/route.ts index 31b744ca..562d3bf6 100644 --- a/app/api/notifications/route.ts +++ b/app/api/notifications/route.ts @@ -52,6 +52,7 @@ export async function GET(request: NextRequest) { const priorityParam = searchParams.get('priority'); const sortByParam = searchParams.get('sort_by'); const sortOrderParam = searchParams.get('sort_order'); + const searchParam = searchParams.get('search'); // Get all valid notification categories from type definition const validCategories: readonly NotificationCategory[] = [ @@ -97,6 +98,7 @@ export async function GET(request: NextRequest) { sortOrderParam === 'asc' || sortOrderParam === 'desc' ? sortOrderParam : 'desc', + search: searchParam || undefined, }; // Validate pagination parameters diff --git a/app/notifications/page.tsx b/app/notifications/page.tsx index f2026441..c72b7dfa 100644 --- a/app/notifications/page.tsx +++ b/app/notifications/page.tsx @@ -2,14 +2,23 @@ import { NotificationList } from '@components/notification/notification-list'; import { Button } from '@components/ui/button'; +import { Input } from '@components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@components/ui/select'; +import { Switch } from '@components/ui/switch'; import { Tabs, TabsList, TabsTrigger } from '@components/ui/tabs'; import { useNotificationStore } from '@lib/stores/notification-store'; import { useSidebarStore } from '@lib/stores/sidebar-store'; import type { NotificationTab } from '@lib/types/notification-center'; import { cn } from '@lib/utils'; -import { Bell } from 'lucide-react'; +import { Bell, Check, Filter } from 'lucide-react'; -import { useCallback, useEffect, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslations } from 'next-intl'; @@ -37,11 +46,26 @@ export default function NotificationsPage() { isLoading, activeTab, setActiveTab, + markAllAsRead, } = useNotificationStore(); + const [search, setSearch] = useState(''); + const [sortBy, setSortBy] = useState<'created_at' | 'priority'>('created_at'); + const [sortOrder, setSortOrder] = useState<'desc' | 'asc'>('desc'); + const [includeRead, setIncludeRead] = useState(true); + useEffect(() => { - fetchNotifications(); - }, [fetchNotifications]); + const timeout = setTimeout(() => { + fetchNotifications({ + search: search.trim() || undefined, + sort_by: sortBy, + sort_order: sortOrder, + include_read: includeRead, + }); + }, 200); + + return () => clearTimeout(timeout); + }, [fetchNotifications, search, sortBy, sortOrder, includeRead, activeTab]); const sidebarPaddingClass = useMemo( () => (isExpanded ? 'md:pl-64 xl:pl-72' : 'md:pl-16 lg:pl-20'), @@ -66,6 +90,24 @@ export default function NotificationsPage() { void loadMore(); }, [loadMore]); + const handleMarkAllAsRead = useCallback(() => { + void markAllAsRead(); + }, [markAllAsRead]); + + const handleIncludeReadToggle = useCallback( + (checked: boolean) => { + setIncludeRead(checked); + // When turning off includeRead, ensure we re-fetch unread-only + void fetchNotifications({ + search: search.trim() || undefined, + sort_by: sortBy, + sort_order: sortOrder, + include_read: checked, + }); + }, + [fetchNotifications, search, sortBy, sortOrder] + ); + const shouldShowEmpty = !isLoading && notifications.length === 0; return ( @@ -94,6 +136,70 @@ export default function NotificationsPage() { +
+
+
+ setSearch(e.target.value)} + className="w-full md:max-w-xs" + /> + +
+ +
+
+ + {tPage('filters.unreadOnly')} + handleIncludeReadToggle(!checked)} + aria-label={tPage('filters.unreadOnly')} + /> +
+ + +
+
+
+ {shouldShowEmpty ? (
diff --git a/lib/db/notification-center.ts b/lib/db/notification-center.ts index fcab2129..842cd694 100644 --- a/lib/db/notification-center.ts +++ b/lib/db/notification-center.ts @@ -146,6 +146,11 @@ export async function getNotificationsWithReadStatus( query = query.eq('priority', params.priority); } + if (params.search) { + const term = `%${params.search}%`; + query = query.or(`title.ilike.${term},content.ilike.${term}`); + } + // Apply pagination if (params.limit) { query = query.limit(params.limit); @@ -225,6 +230,11 @@ export async function getAllNotificationsForAdmin( query = query.eq('priority', params.priority); } + if (params.search) { + const term = `%${params.search}%`; + query = query.or(`title.ilike.${term},content.ilike.${term}`); + } + // Apply pagination if (params.limit) { query = query.limit(params.limit); diff --git a/lib/types/notification-center.ts b/lib/types/notification-center.ts index 4beeaabd..a785c7e9 100644 --- a/lib/types/notification-center.ts +++ b/lib/types/notification-center.ts @@ -115,6 +115,7 @@ export interface GetNotificationsParams { priority?: NotificationPriority; sort_by?: 'created_at' | 'published_at' | 'priority'; sort_order?: 'asc' | 'desc'; + search?: string; } /** diff --git a/messages/en-US.json b/messages/en-US.json index 5516db2e..25a2f59b 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -31,6 +31,27 @@ }, "viewDetails": "View Details" }, + "notifications": { + "title": "Notifications", + "searchPlaceholder": "Search notifications...", + "empty": { + "title": "No notifications", + "description": "You're all caught up!" + }, + "sort": { + "label": "Sort by", + "newest": "Newest first", + "oldest": "Oldest first", + "priorityHigh": "Priority: high to low", + "priorityLow": "Priority: low to high" + }, + "filters": { + "unreadOnly": "Show unread only" + }, + "actions": { + "markAllRead": "Mark all as read" + } + }, "time": { "notRecorded": "Not recorded", "invalidDate": "Invalid date", diff --git a/messages/zh-CN.json b/messages/zh-CN.json index 0cf6ab09..8e9b4d1d 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -31,6 +31,27 @@ }, "viewDetails": "查看详情" }, + "notifications": { + "title": "通知中心", + "searchPlaceholder": "搜索通知...", + "empty": { + "title": "暂无通知", + "description": "你已处理完所有通知" + }, + "sort": { + "label": "排序", + "newest": "最新优先", + "oldest": "最旧优先", + "priorityHigh": "优先级:高到低", + "priorityLow": "优先级:低到高" + }, + "filters": { + "unreadOnly": "只看未读" + }, + "actions": { + "markAllRead": "全部标记已读" + } + }, "time": { "notRecorded": "未记录", "invalidDate": "无效日期", From 122519164b3f66d954c6566e1c5d00f89f3ec995 Mon Sep 17 00:00:00 2001 From: pixelsama Date: Sun, 23 Nov 2025 17:41:46 +0800 Subject: [PATCH 02/15] fix: correct notifications i18n keys --- messages/en-US.json | 35 ++++++++++++++--------------------- messages/zh-CN.json | 37 +++++++++++++++---------------------- 2 files changed, 29 insertions(+), 43 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 25a2f59b..20ce888f 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -31,27 +31,6 @@ }, "viewDetails": "View Details" }, - "notifications": { - "title": "Notifications", - "searchPlaceholder": "Search notifications...", - "empty": { - "title": "No notifications", - "description": "You're all caught up!" - }, - "sort": { - "label": "Sort by", - "newest": "Newest first", - "oldest": "Oldest first", - "priorityHigh": "Priority: high to low", - "priorityLow": "Priority: low to high" - }, - "filters": { - "unreadOnly": "Show unread only" - }, - "actions": { - "markAllRead": "Mark all as read" - } - }, "time": { "notRecorded": "Not recorded", "invalidDate": "Invalid date", @@ -175,9 +154,23 @@ }, "notifications": { "title": "Notifications", + "searchPlaceholder": "Search notifications...", "empty": { "title": "No notifications", "description": "You're all caught up!" + }, + "sort": { + "label": "Sort by", + "newest": "Newest first", + "oldest": "Oldest first", + "priorityHigh": "Priority: high to low", + "priorityLow": "Priority: low to high" + }, + "filters": { + "unreadOnly": "Show unread only" + }, + "actions": { + "markAllRead": "Mark all as read" } }, "chat": { diff --git a/messages/zh-CN.json b/messages/zh-CN.json index 8e9b4d1d..c3ac3fdd 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -31,27 +31,6 @@ }, "viewDetails": "查看详情" }, - "notifications": { - "title": "通知中心", - "searchPlaceholder": "搜索通知...", - "empty": { - "title": "暂无通知", - "description": "你已处理完所有通知" - }, - "sort": { - "label": "排序", - "newest": "最新优先", - "oldest": "最旧优先", - "priorityHigh": "优先级:高到低", - "priorityLow": "优先级:低到高" - }, - "filters": { - "unreadOnly": "只看未读" - }, - "actions": { - "markAllRead": "全部标记已读" - } - }, "time": { "notRecorded": "未记录", "invalidDate": "无效日期", @@ -174,10 +153,24 @@ } }, "notifications": { - "title": "通知", + "title": "通知中心", + "searchPlaceholder": "搜索通知...", "empty": { "title": "暂无通知", "description": "暂时没有新的提醒。" + }, + "sort": { + "label": "排序", + "newest": "最新优先", + "oldest": "最旧优先", + "priorityHigh": "优先级:高到低", + "priorityLow": "优先级:低到高" + }, + "filters": { + "unreadOnly": "只看未读" + }, + "actions": { + "markAllRead": "全部标记已读" } }, "chat": { From d9119b3790e0b643bd9d64fbd0e045054babe7b5 Mon Sep 17 00:00:00 2001 From: pixelsama Date: Sun, 23 Nov 2025 17:43:56 +0800 Subject: [PATCH 03/15] fix: use correct i18n key for mark all read --- app/notifications/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/notifications/page.tsx b/app/notifications/page.tsx index c72b7dfa..ae108ac5 100644 --- a/app/notifications/page.tsx +++ b/app/notifications/page.tsx @@ -194,7 +194,7 @@ export default function NotificationsPage() { className="flex items-center gap-1" > - {tList('markAllRead') ?? tPage('actions.markAllRead')} + {tPage('actions.markAllRead')}
From 2b71a9d73567fe6b09fc4afba21d9fe0697f40c2 Mon Sep 17 00:00:00 2001 From: pixelsama Date: Sun, 23 Nov 2025 18:47:44 +0800 Subject: [PATCH 04/15] fix: add mobile sidebar layout for notifications --- app/notifications/layout.tsx | 46 ++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 app/notifications/layout.tsx diff --git a/app/notifications/layout.tsx b/app/notifications/layout.tsx new file mode 100644 index 00000000..d12a3d39 --- /dev/null +++ b/app/notifications/layout.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { MobileNavButton } from '@components/mobile'; +import { useMobile } from '@lib/hooks'; +import { useSidebarStore } from '@lib/stores/sidebar-store'; +import { cn } from '@lib/utils'; + +interface NotificationsLayoutProps { + children: React.ReactNode; +} + +export default function NotificationsLayout({ + children, +}: NotificationsLayoutProps) { + const { isExpanded, isMounted } = useSidebarStore(); + const isMobile = useMobile(); + + const getMainMarginLeft = () => { + if (isMobile) return 'ml-0'; + return isExpanded ? 'ml-64' : 'ml-16'; + }; + + return ( +
+ {/* Mobile navigation trigger */} +
+ {isMounted && } +
+ +
+
{children}
+
+
+ ); +} From 3271e97076dcb238202e243a1e3a9f7e35a8dfda Mon Sep 17 00:00:00 2001 From: pixelsama Date: Sun, 23 Nov 2025 19:03:40 +0800 Subject: [PATCH 05/15] feat: add admin notifications crud ui --- app/admin/notifications/[id]/edit/page.tsx | 11 + app/admin/notifications/new/page.tsx | 7 + app/admin/notifications/page.tsx | 334 ++++++++++++++++++ .../shared/notification-form.tsx | 261 ++++++++++++++ 4 files changed, 613 insertions(+) create mode 100644 app/admin/notifications/[id]/edit/page.tsx create mode 100644 app/admin/notifications/new/page.tsx create mode 100644 app/admin/notifications/page.tsx create mode 100644 app/admin/notifications/shared/notification-form.tsx diff --git a/app/admin/notifications/[id]/edit/page.tsx b/app/admin/notifications/[id]/edit/page.tsx new file mode 100644 index 00000000..098b29b2 --- /dev/null +++ b/app/admin/notifications/[id]/edit/page.tsx @@ -0,0 +1,11 @@ +'use client'; + +import { NotificationForm } from '../../shared/notification-form'; + +interface EditPageProps { + params: { id: string }; +} + +export default function AdminNotificationEditPage({ params }: EditPageProps) { + return ; +} diff --git a/app/admin/notifications/new/page.tsx b/app/admin/notifications/new/page.tsx new file mode 100644 index 00000000..185d5672 --- /dev/null +++ b/app/admin/notifications/new/page.tsx @@ -0,0 +1,7 @@ +'use client'; + +import { NotificationForm } from '../shared/notification-form'; + +export default function AdminNotificationCreatePage() { + return ; +} diff --git a/app/admin/notifications/page.tsx b/app/admin/notifications/page.tsx new file mode 100644 index 00000000..5fb2d169 --- /dev/null +++ b/app/admin/notifications/page.tsx @@ -0,0 +1,334 @@ +'use client'; + +import { Button } from '@components/ui/button'; +import { Input } from '@components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@components/ui/select'; +import { Switch } from '@components/ui/switch'; +import type { Notification } from '@lib/types/notification-center'; +import { cn } from '@lib/utils'; +import { Loader2, Plus, RefreshCw, Trash2 } from 'lucide-react'; + +import { useEffect, useMemo, useState } from 'react'; + +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; + +type AdminNotification = Notification; + +export default function AdminNotificationsPage() { + const router = useRouter(); + const [notifications, setNotifications] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [includeDrafts, setIncludeDrafts] = useState(true); + const [sortBy, setSortBy] = useState<'created_at' | 'priority'>('created_at'); + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); + const [search, setSearch] = useState(''); + + const filteredNotifications = useMemo(() => { + const term = search.trim().toLowerCase(); + return notifications.filter(n => { + if (!includeDrafts && !n.published) return false; + if (!term) return true; + return ( + n.title.toLowerCase().includes(term) || + n.content.toLowerCase().includes(term) || + (n.category || '').toLowerCase().includes(term) + ); + }); + }, [notifications, includeDrafts, search]); + + const sortedNotifications = useMemo(() => { + const list = [...filteredNotifications]; + list.sort((a, b) => { + if (sortBy === 'priority') { + const order = ['low', 'medium', 'high', 'critical'] as const; + const aIdx = order.indexOf(a.priority); + const bIdx = order.indexOf(b.priority); + return sortOrder === 'asc' ? aIdx - bIdx : bIdx - aIdx; + } + // created_at + const aTime = new Date(a.created_at).getTime(); + const bTime = new Date(b.created_at).getTime(); + return sortOrder === 'asc' ? aTime - bTime : bTime - aTime; + }); + return list; + }, [filteredNotifications, sortBy, sortOrder]); + + const fetchData = async () => { + try { + setIsLoading(true); + setError(null); + const params = new URLSearchParams({ + limit: '200', + sort_by: sortBy, + sort_order: sortOrder, + }); + const res = await fetch(`/api/admin/notifications?${params.toString()}`); + if (!res.ok) { + throw new Error('Failed to fetch notifications'); + } + const data = await res.json(); + setNotifications(data.notifications || []); + } catch (e) { + setError(e instanceof Error ? e.message : 'Unknown error'); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + void fetchData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const toggleSelect = (id: string) => { + setSelectedIds(prev => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const toggleSelectAll = () => { + if (selectedIds.size === sortedNotifications.length) { + setSelectedIds(new Set()); + } else { + setSelectedIds(new Set(sortedNotifications.map(n => n.id))); + } + }; + + const handleBulkDelete = async () => { + if (selectedIds.size === 0) return; + setIsLoading(true); + try { + const res = await fetch('/api/admin/notifications/bulk', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'delete', + notification_ids: Array.from(selectedIds), + }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || 'Failed to delete notifications'); + } + await fetchData(); + setSelectedIds(new Set()); + } catch (e) { + setError(e instanceof Error ? e.message : 'Unknown error'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+
+

Notifications (Admin)

+

+ Manage notifications, publish status, and bulk operations. +

+
+
+ + + + +
+
+ +
+
+
+ setSearch(e.target.value)} + className="md:max-w-sm" + /> + +
+
+ Show drafts + +
+
+ + {error && ( +
+ {error} +
+ )} + +
+ + + + + + + + + + + + + + + {sortedNotifications.map(notification => { + const isSelected = selectedIds.has(notification.id); + return ( + + + + + + + + + + + ); + })} + {sortedNotifications.length === 0 && !isLoading && ( + + + + )} + +
+ 0 + } + onChange={toggleSelectAll} + aria-label="Select all" + /> + TitleTypeCategoryPriorityPublishedCreatedActions
+ toggleSelect(notification.id)} + aria-label="Select row" + /> + +
{notification.title}
+
+ {notification.content} +
+
+ {notification.type} + + {notification.category || '-'} + + {notification.priority} + + {notification.published ? ( + + Published + + ) : ( + + Draft + + )} + + {new Date(notification.created_at).toLocaleString()} + +
+ +
+
+ No notifications found. +
+
+ +
+
+ {sortedNotifications.length} items +
+
+ +
+
+
+
+ ); +} diff --git a/app/admin/notifications/shared/notification-form.tsx b/app/admin/notifications/shared/notification-form.tsx new file mode 100644 index 00000000..d00034d8 --- /dev/null +++ b/app/admin/notifications/shared/notification-form.tsx @@ -0,0 +1,261 @@ +'use client'; + +import { Button } from '@components/ui/button'; +import { Input } from '@components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@components/ui/select'; +import { Switch } from '@components/ui/switch'; +import { Textarea } from '@components/ui/textarea'; +import type { + Notification, + NotificationPriority, + NotificationType, +} from '@lib/types/notification-center'; + +import { useEffect, useState } from 'react'; + +interface NotificationFormProps { + mode: 'create' | 'edit'; + id?: string; +} + +const PRIORITIES: NotificationPriority[] = [ + 'low', + 'medium', + 'high', + 'critical', +]; + +const TYPES: NotificationType[] = ['changelog', 'message']; + +export function NotificationForm({ mode, id }: NotificationFormProps) { + const [form, setForm] = useState({ + type: 'message' as NotificationType, + category: '', + title: '', + content: '', + priority: 'medium' as NotificationPriority, + published: false, + }); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + useEffect(() => { + if (mode === 'edit' && id) { + const fetchDetail = async () => { + setIsLoading(true); + setError(null); + try { + const res = await fetch(`/api/notifications/${id}`); + if (!res.ok) throw new Error('Failed to load notification'); + const data: Notification = await res.json(); + setForm({ + type: data.type, + category: data.category || '', + title: data.title, + content: data.content, + priority: data.priority, + published: data.published, + }); + } catch (e) { + setError(e instanceof Error ? e.message : 'Unknown error'); + } finally { + setIsLoading(false); + } + }; + void fetchDetail(); + } + }, [mode, id]); + + const handleChange = ( + key: keyof typeof form, + value: string | boolean | NotificationType | NotificationPriority + ) => { + setForm(prev => ({ + ...prev, + [key]: value, + })); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + setError(null); + setSuccess(null); + + try { + if (mode === 'create') { + const res = await fetch('/api/admin/notifications', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(form), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || 'Failed to create notification'); + } + setSuccess('Created successfully'); + setForm({ + type: 'message', + category: '', + title: '', + content: '', + priority: 'medium', + published: false, + }); + } else if (mode === 'edit' && id) { + const res = await fetch(`/api/notifications/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(form), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || 'Failed to update notification'); + } + setSuccess('Updated successfully'); + } + } catch (e) { + setError(e instanceof Error ? e.message : 'Unknown error'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+
+

+ {mode === 'create' ? 'New Notification' : 'Edit Notification'} +

+

+ {mode === 'create' + ? 'Create and publish a notification to users.' + : 'Update notification content, priority, or publish status.'} +

+
+
+ + {error && ( +
+ {error} +
+ )} + {success && ( +
+ {success} +
+ )} + +
+
+
+ + +
+ +
+ + handleChange('category', e.target.value)} + /> +
+ +
+ + +
+ +
+ +
+ handleChange('published', checked)} + /> + + {form.published ? 'Published' : 'Draft'} + +
+
+
+ +
+ + handleChange('title', e.target.value)} + required + /> +
+ +
+ +