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..23d60703 --- /dev/null +++ b/app/admin/notifications/page.tsx @@ -0,0 +1,344 @@ +'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 { useCallback, useEffect, useMemo, useState } from 'react'; + +import { useTranslations } from 'next-intl'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; + +type AdminNotification = Notification; + +export default function AdminNotificationsPage() { + const router = useRouter(); + const t = useTranslations('pages.admin.notifications'); + const tFilters = useTranslations('pages.admin.notifications.filters'); + const tTable = useTranslations('pages.admin.notifications.table'); + const tActions = useTranslations('pages.admin.notifications.actions'); + 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 = useCallback(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); + } + }, [sortBy, sortOrder]); + + useEffect(() => { + void fetchData(); + }, [fetchData]); + + 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 ( +
+
+
+

{t('title')}

+

{t('subtitle')}

+
+
+ + + + +
+
+ +
+
+
+ setSearch(e.target.value)} + className="md:max-w-sm" + /> + +
+
+ {tFilters('showDrafts')} + +
+
+ + {error && ( +
+ {error} +
+ )} + +
+ + + + + + + + + + + + + + + {sortedNotifications.map(notification => { + const isSelected = selectedIds.has(notification.id); + return ( + + + + + + + + + + + ); + })} + {sortedNotifications.length === 0 && !isLoading && ( + + + + )} + +
+ 0 + } + onChange={toggleSelectAll} + aria-label="Select all" + /> + {tTable('title')}{tTable('type')}{tTable('category')}{tTable('priority')}{tTable('published')}{tTable('created')}{tTable('actions')}
+ toggleSelect(notification.id)} + aria-label="Select row" + /> + +
{notification.title}
+
+ {notification.content} +
+
+ {notification.type} + + {notification.category || '-'} + + {notification.priority} + + {notification.published ? ( + + {tTable('publishedBadge')} + + ) : ( + + {tTable('draftBadge')} + + )} + + {new Date(notification.created_at).toLocaleString()} + +
+ +
+
+ {tTable('empty')} +
+
+ +
+
+ {t('count', { count: sortedNotifications.length })} +
+
+ +
+
+
+
+ ); +} diff --git a/app/admin/notifications/shared/notification-form.tsx b/app/admin/notifications/shared/notification-form.tsx new file mode 100644 index 00000000..7fac23e9 --- /dev/null +++ b/app/admin/notifications/shared/notification-form.tsx @@ -0,0 +1,260 @@ +'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'; + +import { useTranslations } from 'next-intl'; + +interface NotificationFormProps { + mode: 'create' | 'edit'; + id?: string; +} + +const PRIORITIES: NotificationPriority[] = [ + 'low', + 'medium', + 'high', + 'critical', +]; + +const TYPES: NotificationType[] = ['changelog', 'message']; + +const INITIAL_FORM_STATE = { + type: 'message' as NotificationType, + category: '', + title: '', + content: '', + priority: 'medium' as NotificationPriority, + published: false, +}; + +export function NotificationForm({ mode, id }: NotificationFormProps) { + const t = useTranslations('pages.admin.notifications.form'); + const tActions = useTranslations('pages.admin.notifications.actions'); + const [form, setForm] = useState(INITIAL_FORM_STATE); + 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(INITIAL_FORM_STATE); + } 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' ? t('title.new') : t('title.edit')} +

+

+ {mode === 'create' ? t('subtitle.new') : t('subtitle.edit')} +

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