diff --git a/app/admin/hall-of-fame/page.tsx b/app/admin/hall-of-fame/page.tsx index 18a1720..7233dd9 100644 --- a/app/admin/hall-of-fame/page.tsx +++ b/app/admin/hall-of-fame/page.tsx @@ -1,5 +1,5 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useCallback } from "react"; import { useRouter } from "next/navigation"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -30,6 +30,7 @@ interface HallOfFameEntry { researcherName: string; avatarUrl: string | null; title: string; + publicTitle: string | null; severity: string; pointsAwarded: number; acceptedAt: number; @@ -58,6 +59,14 @@ interface Toast { type: 'success' | 'error'; } +interface PointsModal { + id: string; + researcherName: string; + currentPoints: number; + newPoints: string; + reason: string; +} + // ─── Main Component ─────────────────────────────────────────────────────────── export default function AdminHallOfFame() { @@ -75,6 +84,15 @@ export default function AdminHallOfFame() { const [showConfigEdit, setShowConfigEdit] = useState(false); const [editingConfig, setEditingConfig] = useState<{ severity: string; points: number } | null>(null); + // New state for VAN-17 features + const [selectedEntryIds, setSelectedEntryIds] = useState>(new Set()); + const [editingTitle, setEditingTitle] = useState<{ id: string; value: string } | null>(null); + const [pointsModal, setPointsModal] = useState(null); + + const showToast = useCallback((message: string, type: 'success' | 'error') => { + setToast({ message, type }); + }, []); + useEffect(() => { fetchData(); }, []); @@ -86,6 +104,11 @@ export default function AdminHallOfFame() { } }, [toast]); + // Clear selection when page or search changes + useEffect(() => { + setSelectedEntryIds(new Set()); + }, [entriesPage, entriesSearchQuery]); + async function fetchData() { try { const [leaderboardRes, entriesRes, configRes, statsRes] = await Promise.all([ @@ -116,7 +139,7 @@ export default function AdminHallOfFame() { } } catch (error) { console.error('[Admin Hall of Fame] Error fetching data:', error); - setToast({ message: 'Failed to load data', type: 'error' }); + showToast('Failed to load data', 'error'); } finally { setLoading(false); } @@ -139,13 +162,10 @@ export default function AdminHallOfFame() { await fetchData(); setEditingConfig(null); - setToast({ message: 'Points configuration updated successfully!', type: 'success' }); + showToast('Points configuration updated successfully!', 'success'); } catch (error) { console.error('[handleUpdatePoints] Error:', error); - setToast({ - message: error instanceof Error ? error.message : 'Failed to update points', - type: 'error', - }); + showToast(error instanceof Error ? error.message : 'Failed to update points', 'error'); } } @@ -154,9 +174,7 @@ export default function AdminHallOfFame() { const res = await fetch(`/api/admin/hall-of-fame/${entryId}/visibility`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - isPublic: !currentVisibility, - }), + body: JSON.stringify({ isPublic: !currentVisibility }), }); if (!res.ok) { @@ -165,26 +183,188 @@ export default function AdminHallOfFame() { } await fetchData(); - setToast({ - message: currentVisibility ? 'Entry hidden from public' : 'Entry made public', - type: 'success', - }); + showToast(currentVisibility ? 'Entry hidden from public' : 'Entry made public', 'success'); } catch (error) { console.error('[handleToggleVisibility] Error:', error); - setToast({ - message: error instanceof Error ? error.message : 'Failed to toggle visibility', - type: 'error', + showToast(error instanceof Error ? error.message : 'Failed to toggle visibility', 'error'); + } + } + + // ── Title editing ───────────────────────────────────────────────────────── + + async function handleSaveTitle(entryId: string) { + if (!editingTitle) return; + const value = editingTitle.value.trim(); + + try { + const res = await fetch(`/api/admin/hall-of-fame/${entryId}/title`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ publicTitle: value || null }), }); + + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || 'Failed to update title'); + } + + await fetchData(); + setEditingTitle(null); + showToast('Title updated', 'success'); + } catch (error) { + console.error('[handleSaveTitle] Error:', error); + showToast(error instanceof Error ? error.message : 'Failed to update title', 'error'); } } + // ── Points adjustment ──────────────────────────────────────────────────── + + async function handleAdjustPoints() { + if (!pointsModal) return; + const newPoints = parseInt(pointsModal.newPoints, 10); + + if (isNaN(newPoints) || newPoints < 0 || newPoints > 10000) { + showToast('Points must be between 0 and 10,000', 'error'); + return; + } + if (!pointsModal.reason.trim()) { + showToast('A reason is required', 'error'); + return; + } + + try { + const res = await fetch(`/api/admin/hall-of-fame/${pointsModal.id}/points`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ points: newPoints, reason: pointsModal.reason.trim() }), + }); + + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || 'Failed to adjust points'); + } + + await fetchData(); + setPointsModal(null); + showToast('Points adjusted successfully', 'success'); + } catch (error) { + console.error('[handleAdjustPoints] Error:', error); + showToast(error instanceof Error ? error.message : 'Failed to adjust points', 'error'); + } + } + + // ── Bulk visibility ────────────────────────────────────────────────────── + + async function handleBulkVisibility(isPublic: boolean) { + if (selectedEntryIds.size === 0) return; + + try { + const res = await fetch('/api/admin/hall-of-fame/bulk-visibility', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids: Array.from(selectedEntryIds), isPublic }), + }); + + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || 'Failed to update visibility'); + } + + const data = await res.json(); + await fetchData(); + setSelectedEntryIds(new Set()); + showToast(`${data.updated} ${data.updated === 1 ? 'entry' : 'entries'} ${isPublic ? 'made public' : 'hidden'}`, 'success'); + } catch (error) { + console.error('[handleBulkVisibility] Error:', error); + showToast(error instanceof Error ? error.message : 'Failed to update visibility', 'error'); + } + } + + // ── Leaderboard CSV export ─────────────────────────────────────────────── + + async function handleExportLeaderboardCSV() { + let exportLeaderboard = leaderboard; + + try { + const res = await fetch('/api/admin/hall-of-fame/leaderboard?limit=all'); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || 'Failed to export leaderboard'); + } + const data = await res.json(); + exportLeaderboard = data.leaderboard || []; + } catch (error) { + console.error('[handleExportLeaderboardCSV] Error:', error); + showToast(error instanceof Error ? error.message : 'Failed to export leaderboard', 'error'); + return; + } + + const headers = ['Rank', 'Researcher', 'Points', 'Critical', 'High', 'Medium', 'Low', 'Accepted Reports', 'First Report', 'Last Report']; + const rows = exportLeaderboard.map((e) => [ + e.rank, + e.researcherName, + e.totalPoints, + e.criticalCount, + e.highCount, + e.mediumCount, + e.lowCount, + e.acceptedReports, + e.firstReportAt ? new Date(e.firstReportAt).toISOString().split('T')[0] : '', + e.lastReportAt ? new Date(e.lastReportAt).toISOString().split('T')[0] : '', + ]); + + const csv = [headers, ...rows] + .map((row) => row.map((v) => `"${String(v).replace(/"/g, '""')}"`).join(',')) + .join('\n'); + + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `leaderboard-${new Date().toISOString().split('T')[0]}.csv`; + a.click(); + URL.revokeObjectURL(url); + showToast('Leaderboard CSV exported', 'success'); + } + + // ── Selection helpers ──────────────────────────────────────────────────── + + function handleToggleSelect(id: string) { + setSelectedEntryIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + function handleSelectAllOnPage() { + const pageIds = paginatedEntries.map((e) => e.id); + const allSelected = pageIds.every((id) => selectedEntryIds.has(id)); + if (allSelected) { + setSelectedEntryIds((prev) => { + const next = new Set(prev); + pageIds.forEach((id) => next.delete(id)); + return next; + }); + } else { + setSelectedEntryIds((prev) => { + const next = new Set(prev); + pageIds.forEach((id) => next.add(id)); + return next; + }); + } + } + + // ── Derived data ───────────────────────────────────────────────────────── + const filteredLeaderboard = leaderboard.filter((entry) => entry.researcherName.toLowerCase().includes(searchQuery.toLowerCase()) ); const filteredEntries = entries.filter((entry) => entry.researcherName.toLowerCase().includes(entriesSearchQuery.toLowerCase()) || - entry.title.toLowerCase().includes(entriesSearchQuery.toLowerCase()) + (entry.publicTitle ?? entry.title).toLowerCase().includes(entriesSearchQuery.toLowerCase()) ); const totalEntriesPages = Math.ceil(filteredEntries.length / entriesPerPage); @@ -193,6 +373,9 @@ export default function AdminHallOfFame() { entriesPage * entriesPerPage ); + const allPageSelected = paginatedEntries.length > 0 && paginatedEntries.every((e) => selectedEntryIds.has(e.id)); + const somePageSelected = paginatedEntries.some((e) => selectedEntryIds.has(e.id)); + const displayStats = stats || { totalPointsAwarded: leaderboard.reduce((sum, r) => sum + r.totalPoints, 0), totalResearchers: leaderboard.length, @@ -216,6 +399,57 @@ export default function AdminHallOfFame() { )} + {/* Points adjustment modal */} + {pointsModal && ( +
+
+

Adjust Points

+

+ Adjusting points for {pointsModal.researcherName}. + Current: {pointsModal.currentPoints} pts +

+
+ + setPointsModal({ ...pointsModal, newPoints: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + autoFocus + /> +
+
+ +