From 90d26a97bdf3f054ef26ffde25a799fcfd023be0 Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Sat, 3 Jan 2026 20:31:34 -0500 Subject: [PATCH 0001/1040] Update status page to work with proper architecture --- .../modtale/controller/StatusController.java | 45 ++++++----- .../analytics/StatusHistoryRepository.java | 1 + frontend/src/react-pages/Status.tsx | 74 +++++-------------- 3 files changed, 45 insertions(+), 75 deletions(-) diff --git a/backend/src/main/java/net/modtale/controller/StatusController.java b/backend/src/main/java/net/modtale/controller/StatusController.java index da02025a..652f939e 100644 --- a/backend/src/main/java/net/modtale/controller/StatusController.java +++ b/backend/src/main/java/net/modtale/controller/StatusController.java @@ -2,6 +2,8 @@ import net.modtale.model.analytics.StatusHistory; import net.modtale.repository.analytics.StatusHistoryRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.event.EventListener; @@ -25,23 +27,34 @@ @RequestMapping("/api/v1/status") public class StatusController { + private static final Logger logger = LoggerFactory.getLogger(StatusController.class); + @Autowired private MongoTemplate mongoTemplate; @Autowired private S3Client s3Client; @Autowired private StatusHistoryRepository historyRepository; @EventListener(ApplicationReadyEvent.class) public void onStartup() { - checkAndRecordStatus(); + performHealthCheck(); } @Scheduled(fixedRate = 60000) public void performScheduledCheck() { - checkAndRecordStatus(); + performHealthCheck(); } @GetMapping public ResponseEntity> getSystemStatus(@RequestParam(defaultValue = "24h") String range) { - Map currentStatus = checkAndRecordStatus(); + StatusHistory latest = historyRepository.findTopByOrderByTimestampDesc(); + + if (latest == null) { + latest = performHealthCheck(); + } + + List> services = new ArrayList<>(); + services.add(Map.of("id", "api", "name", "API Gateway", "status", latest.getOverallStatus(), "latency", latest.getApiLatency())); + services.add(Map.of("id", "database", "name", "Database (Atlas)", "status", "operational", "latency", latest.getDbLatency())); // Assuming simple mapping, could store individual status in DB if needed + services.add(Map.of("id", "storage", "name", "Storage (R2)", "status", "operational", "latency", latest.getStorageLatency())); LocalDateTime since; if ("30d".equals(range)) { @@ -62,9 +75,9 @@ public ResponseEntity> getSystemStatus(@RequestParam(default } return ResponseEntity.ok(Map.of( - "overall", currentStatus.get("overall"), - "services", currentStatus.get("services"), - "timestamp", System.currentTimeMillis(), + "overall", latest.getOverallStatus(), + "services", services, + "timestamp", latest.getTimestamp().toEpochSecond(ZoneOffset.UTC) * 1000, "history", history.stream().map(h -> Map.of( "time", h.getTimestamp().toEpochSecond(ZoneOffset.UTC) * 1000, "api", h.getApiLatency(), @@ -74,39 +87,33 @@ public ResponseEntity> getSystemStatus(@RequestParam(default )); } - private Map checkAndRecordStatus() { - List> services = new ArrayList<>(); + private StatusHistory performHealthCheck() { + long totalStart = System.currentTimeMillis(); boolean allOperational = true; long dbStart = System.currentTimeMillis(); - String dbStatus = "operational"; try { mongoTemplate.executeCommand("{ ping: 1 }"); } catch (Exception e) { - dbStatus = "outage"; + logger.error("Health Check: Database failed", e); allOperational = false; } int dbLatency = (int) (System.currentTimeMillis() - dbStart); - services.add(Map.of("id", "database", "name", "Database (Atlas)", "status", dbStatus, "latency", dbLatency)); long storageStart = System.currentTimeMillis(); - String storageStatus = "operational"; try { s3Client.listBuckets(); } catch (Exception e) { - storageStatus = "outage"; + logger.error("Health Check: Storage failed", e); allOperational = false; } int storageLatency = (int) (System.currentTimeMillis() - storageStart); - services.add(Map.of("id", "storage", "name", "Storage (R2)", "status", storageStatus, "latency", storageLatency)); - services.add(Map.of("id", "api", "name", "API Gateway", "status", "operational", "latency", 5)); + int apiLatency = (int) (System.currentTimeMillis() - totalStart); String overall = allOperational ? "operational" : "degraded"; - StatusHistory entry = new StatusHistory(5, dbLatency, storageLatency, overall); - historyRepository.save(entry); - - return Map.of("overall", overall, "services", services); + StatusHistory entry = new StatusHistory(apiLatency, dbLatency, storageLatency, overall); + return historyRepository.save(entry); } } \ No newline at end of file diff --git a/backend/src/main/java/net/modtale/repository/analytics/StatusHistoryRepository.java b/backend/src/main/java/net/modtale/repository/analytics/StatusHistoryRepository.java index 16c46606..2ec3a22f 100644 --- a/backend/src/main/java/net/modtale/repository/analytics/StatusHistoryRepository.java +++ b/backend/src/main/java/net/modtale/repository/analytics/StatusHistoryRepository.java @@ -7,4 +7,5 @@ public interface StatusHistoryRepository extends MongoRepository { List findByTimestampAfterOrderByTimestampAsc(LocalDateTime timestamp); + StatusHistory findTopByOrderByTimestampDesc(); } \ No newline at end of file diff --git a/frontend/src/react-pages/Status.tsx b/frontend/src/react-pages/Status.tsx index 37e90bb5..3a73e85b 100644 --- a/frontend/src/react-pages/Status.tsx +++ b/frontend/src/react-pages/Status.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState, useMemo } from 'react'; import { api } from '../utils/api'; import { CheckCircle, AlertTriangle, XCircle, Server, Database, - HardDrive, Globe, RefreshCw, Activity, Zap, BarChart2, Info + HardDrive, Globe, RefreshCw, Activity, Zap, BarChart2 } from 'lucide-react'; import { LineChart } from '../components/ui/charts/LineChart.tsx'; import { Spinner } from '../components/ui/Spinner'; @@ -53,7 +53,7 @@ const UptimeHeatmap: React.FC = ({ serviceId, data, range, l const points = data.filter(d => d.time >= bucketStart && d.time < bucketEnd); - let status: 'operational' | 'degraded' | 'down' | 'idle' = 'idle'; + let status: 'operational' | 'degraded' | 'down' | 'no-data' = 'no-data'; let uptimePercent = 0; if (points.length > 0) { @@ -79,7 +79,7 @@ const UptimeHeatmap: React.FC = ({ serviceId, data, range, l case 'operational': return 'bg-emerald-500 dark:bg-emerald-500 hover:bg-emerald-400'; case 'degraded': return 'bg-yellow-500 dark:bg-yellow-500 hover:bg-yellow-400'; case 'down': return 'bg-red-500 dark:bg-red-500 hover:bg-red-400'; - default: return 'bg-slate-200 dark:bg-white/10 hover:bg-slate-300 dark:hover:bg-white/20'; + default: return 'bg-slate-100 dark:bg-white/5'; // No Data } }; @@ -98,9 +98,9 @@ const UptimeHeatmap: React.FC = ({ serviceId, data, range, l - {hoveredBar.label}: {hoveredBar.status === 'idle' ? 'Sleeping' : `${hoveredBar.percent.toFixed(1)}%`} + {hoveredBar.label}: {hoveredBar.status === 'no-data' ? 'No Data' : `${hoveredBar.percent.toFixed(1)}%`} ) : ( range === '24h' ? 'Uptime: 24h' : 'Uptime: 30d' @@ -123,7 +123,7 @@ const UptimeHeatmap: React.FC = ({ serviceId, data, range, l }; const StatusIcon = ({ status }: { status: string }) => { - if (status === 'operational') return ; + if (status === 'operational') return ; if (status === 'degraded') return ; return ; }; @@ -144,16 +144,9 @@ export const Status: React.FC = () => { const fetchStatus = async () => { setIsRefreshing(true); - const start = Date.now(); try { const res = await api.get(`/status?range=${range}`); - const apiLatency = Date.now() - start; - - const services = res.data.services.map((s: ServiceStatus) => - s.id === 'api' ? { ...s, latency: apiLatency } : s - ); - - setData({ ...res.data, services }); + setData(res.data); setLastUpdated(new Date()); } catch (e) { console.error(e); @@ -173,23 +166,7 @@ export const Status: React.FC = () => { }, [range]); const displayHistory = useMemo(() => { - let history: HistoryPoint[] = data?.history ? [...data.history] : []; - const currentPoint: HistoryPoint = { - time: data?.timestamp || Date.now(), - api: data?.services?.find(s => s.id === 'api')?.latency || 0, - db: data?.services?.find(s => s.id === 'database')?.latency || 0, - storage: data?.services?.find(s => s.id === 'storage')?.latency || 0 - }; - - if (history.length === 0) { - history.push(currentPoint); - } else if (currentPoint.time > history[history.length - 1].time) { - history.push(currentPoint); - } - - if (history.length === 1) { - history.unshift({ ...history[0], time: history[0].time - 60000 }); - } + const history: HistoryPoint[] = data?.history ? [...data.history] : []; return history; }, [data]); @@ -209,32 +186,17 @@ export const Status: React.FC = () => { ]; const overallColor = data?.overall === 'operational' ? 'bg-emerald-500' : (data?.overall === 'degraded' ? 'bg-yellow-500' : 'bg-red-500'); - const overallText = data?.overall === 'operational' ? 'All Systems Operational' : (data?.overall === 'degraded' ? 'Partial System Degraded' : 'Major System Outage'); + const overallText = data?.overall === 'operational' ? 'All Systems Operational' : (data?.overall === 'degraded' ? 'Partial System Degradation' : 'Major System Outage'); - if (loading && !data) return
; + if (loading && !data) return
; return (
-
+

System Status

-

Live performance and reliability tracking.

-
- -
- -
-

Demo Environment: Scale-to-Zero Architecture

-

- Modtale is currently running in a cost-optimized Demo State. - The backend infrastructure is configured to auto-scale down to 0 instances when idle. -
- You may observe "Sleeping" periods (gray bars) or brief "Cold Start" latency in the graphs below. - This is expected behavior for the demo. Once the site exits demo mode, - always-on instances will be provisioned for 99.9%+ availability and significantly faster response times. -

-
+

Real-time performance and reliability monitoring.

@@ -264,16 +226,16 @@ export const Status: React.FC = () => {
Operational
-
Partial Degraded
+
Degraded
Outage
-
Sleeping (Demo Mode)
+
No Data

- Response Times + Response Latency

`${Math.round(v)}ms`} /> @@ -282,14 +244,14 @@ export const Status: React.FC = () => {
{data?.services.map((service) => ( -
+
{service.name}
- Current Latency + Latency {Math.round(service.latency)}ms
@@ -304,7 +266,7 @@ export const Status: React.FC = () => { className="flex items-center gap-2 px-8 py-3 bg-white dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-xl font-bold text-slate-600 dark:text-slate-300 hover:border-modtale-accent transition-all active:scale-95 disabled:opacity-50" > - Force Refresh + Refresh Status
From 3bb0b732dd621f24fad30ed8d343cf3c294fbe7a Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Sat, 3 Jan 2026 23:15:29 -0500 Subject: [PATCH 0002/1040] Partially revert sitemap changes --- .../net/modtale/controller/SitemapController.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/backend/src/main/java/net/modtale/controller/SitemapController.java b/backend/src/main/java/net/modtale/controller/SitemapController.java index 6a6fcc67..905654e3 100644 --- a/backend/src/main/java/net/modtale/controller/SitemapController.java +++ b/backend/src/main/java/net/modtale/controller/SitemapController.java @@ -44,7 +44,7 @@ public String generateSitemap() { if ("MODPACK".equals(p.getClassification())) prefix = "/modpack/"; else if ("SAVE".equals(p.getClassification())) prefix = "/world/"; - String slug = (p.getSlug() != null && !p.getSlug().isBlank()) ? p.getSlug() : p.getId(); + String slug = (p.getSlug() != null && !p.getSlug().isBlank()) ? p.getSlug() : createSlug(p.getTitle(), p.getId()); if (p.getUpdatedAt() != null) { addUrl(xml, baseUrl + prefix + slug, "0.8", parseDate(p.getUpdatedAt())); @@ -78,4 +78,13 @@ private LocalDate parseDate(String dateStr) { return LocalDate.now(); } } + + private String createSlug(String title, String id) { + if (title == null) return id; + String slug = title.toLowerCase() + .replaceAll("[^a-z0-9]+", "-") + .replaceAll("(^-|-$)", ""); + if (slug.length() > 30) slug = slug.substring(0, 30); + return slug.isEmpty() ? id : slug + "-" + id; + } } \ No newline at end of file From 0332c7d03c599b7155625ba1c9232fa680f571ec Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Sat, 3 Jan 2026 23:36:30 -0500 Subject: [PATCH 0003/1040] Update role assignment API --- .../modtale/controller/AdminController.java | 34 ++++++++++++++++--- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/backend/src/main/java/net/modtale/controller/AdminController.java b/backend/src/main/java/net/modtale/controller/AdminController.java index 71f47d33..62d2264b 100644 --- a/backend/src/main/java/net/modtale/controller/AdminController.java +++ b/backend/src/main/java/net/modtale/controller/AdminController.java @@ -39,7 +39,7 @@ private boolean isAdmin(User user) { @PostMapping("/users/{username}/tier") public ResponseEntity setUserTier( @PathVariable String username, - @RequestParam ApiKey.Tier tier + @RequestParam String tier ) { User currentUser = userService.getCurrentUser(); if (!isSuperAdmin(currentUser)) { @@ -48,18 +48,25 @@ public ResponseEntity setUserTier( } try { - userService.setUserTier(username, tier); + ApiKey.Tier tierEnum; + if ("USER".equalsIgnoreCase(tier) || "FREE".equalsIgnoreCase(tier)) { + tierEnum = ApiKey.Tier.USER; + } else { + tierEnum = ApiKey.Tier.valueOf(tier.toUpperCase()); + } + + userService.setUserTier(username, tierEnum); return ResponseEntity.ok(Map.of( "status", "success", - "message", "User " + username + " updated to tier " + tier + "message", "User " + username + " updated to tier " + tierEnum )); } catch (IllegalArgumentException e) { - return ResponseEntity.notFound().build(); + return ResponseEntity.badRequest().body(Map.of("error", "Invalid Tier", "message", "Tier must be USER or ENTERPRISE")); } } @PostMapping("/users/{username}/role") - public ResponseEntity setUserRole(@PathVariable String username, @RequestParam String role) { + public ResponseEntity addUserRole(@PathVariable String username, @RequestParam String role) { User currentUser = userService.getCurrentUser(); if (!isSuperAdmin(currentUser)) { return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Only Super Admin can manage roles."); @@ -76,6 +83,23 @@ public ResponseEntity setUserRole(@PathVariable String username, @RequestPara return ResponseEntity.ok().build(); } + @DeleteMapping("/users/{username}/role") + public ResponseEntity removeUserRole(@PathVariable String username, @RequestParam String role) { + User currentUser = userService.getCurrentUser(); + if (!isSuperAdmin(currentUser)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Only Super Admin can manage roles."); + } + + User target = userRepository.findByUsername(username).orElse(null); + if (target == null) return ResponseEntity.notFound().build(); + + if (target.getRoles() != null) { + target.getRoles().remove(role); + userRepository.save(target); + } + return ResponseEntity.ok().build(); + } + @GetMapping("/verification/queue") public ResponseEntity> getVerificationQueue() { User currentUser = userService.getCurrentUser(); From c043749a86fe4eb5bc99461c32b6089fb851ecc6 Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Sat, 3 Jan 2026 23:38:20 -0500 Subject: [PATCH 0004/1040] Update admin permissions --- .../net/modtale/controller/ModController.java | 19 +++++++++++++------ .../modtale/service/resources/ModService.java | 1 - 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/backend/src/main/java/net/modtale/controller/ModController.java b/backend/src/main/java/net/modtale/controller/ModController.java index d15dfae4..edf3ef01 100644 --- a/backend/src/main/java/net/modtale/controller/ModController.java +++ b/backend/src/main/java/net/modtale/controller/ModController.java @@ -39,6 +39,7 @@ public class ModController { private static final Logger logger = LoggerFactory.getLogger(ModController.class); + private static final String SUPER_ADMIN_ID = "692620f7c2f3266e23ac0ded"; @Autowired private ModService modService; @Autowired private UserService userService; @@ -47,6 +48,12 @@ public class ModController { @Autowired private FileValidationService validationService; @Autowired private UserRepository userRepository; + private boolean isAdminOrSuper(User user) { + if (user == null) return false; + if (SUPER_ADMIN_ID.equals(user.getId())) return true; + return user.getRoles() != null && user.getRoles().contains("ADMIN"); + } + @PutMapping("/projects/{id}/icon") public ResponseEntity updateProjectIcon(@PathVariable String id, @RequestParam("file") MultipartFile file) { User user = userService.getCurrentUser(); @@ -89,9 +96,8 @@ public ResponseEntity downloadVersion(@PathVariable String id, @PathVa if ("DRAFT".equals(mod.getStatus()) || "PENDING".equals(mod.getStatus())) { User user = userService.getCurrentUser(); - if (!modService.hasEditPermission(mod, user)) { - boolean isAdmin = user != null && user.getRoles().contains("ADMIN"); - if (!isAdmin) { + if (!isAdminOrSuper(user)) { + if (user == null || !modService.hasEditPermission(mod, user)) { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } } @@ -214,9 +220,10 @@ public ResponseEntity getProject(@PathVariable String id, HttpServletResponse if ("DRAFT".equals(mod.getStatus()) || "PENDING".equals(mod.getStatus())) { User user = userService.getCurrentUser(); - boolean isAdmin = user != null && user.getRoles().contains("ADMIN"); - if (!isAdmin && (user == null || !modService.hasEditPermission(mod, user))) { - return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + if (!isAdminOrSuper(user)) { + if (user == null || !modService.hasEditPermission(mod, user)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } } } diff --git a/backend/src/main/java/net/modtale/service/resources/ModService.java b/backend/src/main/java/net/modtale/service/resources/ModService.java index 3edb1ab9..4ae31a2b 100644 --- a/backend/src/main/java/net/modtale/service/resources/ModService.java +++ b/backend/src/main/java/net/modtale/service/resources/ModService.java @@ -67,7 +67,6 @@ public class ModService { private static final Pattern STRICT_VERSION_PATTERN = Pattern.compile("^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$"); private static final Pattern REPO_URL_PATTERN = Pattern.compile("^https:\\/\\/(github\\.com|gitlab\\.com)\\/[\\w.-]+\\/[\\w.-]+$"); - private static final Pattern SLUG_PATTERN = Pattern.compile("^[a-z0-9](?:[a-z0-9-]{1,48}[a-z0-9])?$"); @Autowired private ModRepository modRepository; From 28fc16a31da899993897c0a4144dcdcb2e9ebfc7 Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Sun, 4 Jan 2026 00:19:27 -0500 Subject: [PATCH 0005/1040] Implement wizard-based admin project approval panel --- .../modtale/controller/AdminController.java | 23 + frontend/src/react-pages/api/AdminPanel.tsx | 825 ++++++++++++++---- 2 files changed, 689 insertions(+), 159 deletions(-) diff --git a/backend/src/main/java/net/modtale/controller/AdminController.java b/backend/src/main/java/net/modtale/controller/AdminController.java index 62d2264b..bb414ed7 100644 --- a/backend/src/main/java/net/modtale/controller/AdminController.java +++ b/backend/src/main/java/net/modtale/controller/AdminController.java @@ -109,6 +109,29 @@ public ResponseEntity> getVerificationQueue() { return ResponseEntity.ok(modService.getPendingProjects()); } + @GetMapping("/projects/{id}/review-details") + public ResponseEntity getProjectReviewDetails(@PathVariable String id) { + User currentUser = userService.getCurrentUser(); + if (!isAdmin(currentUser)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + + Mod mod = modService.getModById(id); + if (mod == null) return ResponseEntity.notFound().build(); + + User author = userRepository.findByUsername(mod.getAuthor()).orElse(null); + Map authorStats = Map.of( + "accountAge", author != null ? author.getCreatedAt() : "Unknown", + "tier", author != null ? author.getTier() : "Unknown", + "totalProjects", author != null ? modService.getCreatorProjects(author.getUsername(), org.springframework.data.domain.Pageable.unpaged()).getTotalElements() : 0 + ); + + return ResponseEntity.ok(Map.of( + "mod", mod, + "authorStats", authorStats + )); + } + @PostMapping("/projects/{id}/reject") public ResponseEntity rejectProject(@PathVariable String id, @RequestBody Map body) { User currentUser = userService.getCurrentUser(); diff --git a/frontend/src/react-pages/api/AdminPanel.tsx b/frontend/src/react-pages/api/AdminPanel.tsx index 1c9945cc..11f0047d 100644 --- a/frontend/src/react-pages/api/AdminPanel.tsx +++ b/frontend/src/react-pages/api/AdminPanel.tsx @@ -1,13 +1,57 @@ -import React, { useState, useEffect, useRef } from 'react'; -import { api } from '../../utils/api.ts'; -import { StatusModal } from '../../components/ui/StatusModal.tsx'; -import { Shield, Search, User as UserIcon, Zap, Check, X, FileText, Clock, ExternalLink } from 'lucide-react'; +import React, { useState, useEffect } from 'react'; +import { api, API_BASE_URL, BACKEND_URL } from '../../utils/api'; +import { StatusModal } from '../../components/ui/StatusModal'; +import { + Shield, Search, User as UserIcon, Check, X, + FileText, ExternalLink, ArrowRight, ArrowLeft, Image as ImageIcon, + Clock, Download, Box, List, Copy, LayoutGrid, AlertTriangle, Zap, Terminal +} from 'lucide-react'; import type { Mod } from '../../types'; interface AdminPanelProps { currentUser: any; } +interface WizardStep { + id: string; + title: string; + icon: React.ReactNode; + rejectReasons: string[]; +} + +const WIZARD_STEPS: WizardStep[] = [ + { + id: 'meta', + title: 'Metadata', + icon: , + rejectReasons: ["Title violates naming conventions", "Incorrect classification selected", "Tags are irrelevant or spam", "Slug/URL is invalid"] + }, + { + id: 'content', + title: 'Content', + icon: , + rejectReasons: ["Inappropriate imagery", "Description contains spam/links", "Low quality assets", "Insufficient description"] + }, + { + id: 'files', + title: 'Files', + icon: , + rejectReasons: ["Malicious code detected", "Invalid file structure", "Broken or missing dependencies", "Version number mismatch"] + }, + { + id: 'author', + title: 'Author', + icon: , + rejectReasons: ["Suspicious account activity", "Impersonating another creator", "Bot-like behavior"] + }, + { + id: 'decision', + title: 'Decision', + icon: , + rejectReasons: ["General quality standards", "Duplicate project"] + } +]; + export const AdminPanel: React.FC = ({ currentUser }) => { const [activeTab, setActiveTab] = useState<'users' | 'verification'>('verification'); const [status, setStatus] = useState(null); @@ -18,10 +62,19 @@ export const AdminPanel: React.FC = ({ currentUser }) => { const [pendingProjects, setPendingProjects] = useState([]); const [loadingQueue, setLoadingQueue] = useState(false); + + const [reviewingProject, setReviewingProject] = useState(null); + const [loadingReview, setLoadingReview] = useState(false); + const [currentStep, setCurrentStep] = useState(0); + const [checklist, setChecklist] = useState>({}); + const [rejectReason, setRejectReason] = useState(''); - const [rejectingId, setRejectingId] = useState(null); + const [showRejectPanel, setShowRejectPanel] = useState(false); + + const [depMeta, setDepMeta] = useState>({}); - const isAdmin = currentUser?.username === 'Villagers654' || (currentUser?.roles && currentUser.roles.includes('ADMIN')); + const isAdmin = currentUser?.roles?.includes('ADMIN') || currentUser?.username === 'Villagers654'; + const isSuperAdmin = currentUser?.username === 'Villagers654'; useEffect(() => { if (activeTab === 'verification' && isAdmin) { @@ -29,6 +82,29 @@ export const AdminPanel: React.FC = ({ currentUser }) => { } }, [activeTab, isAdmin]); + useEffect(() => { + if (!reviewingProject?.mod?.versions?.[0]?.dependencies) return; + + const deps = reviewingProject.mod.versions[0].dependencies; + const fetchMeta = async () => { + const newMeta = { ...depMeta }; + await Promise.all(deps.map(async (d: any) => { + if (newMeta[d.modId]) return; + try { + const res = await api.get(`/projects/${d.modId}/meta`); + newMeta[d.modId] = { + icon: res.data.icon, + title: res.data.title + }; + } catch (e) { + newMeta[d.modId] = { icon: '', title: d.modTitle || d.modId }; + } + })); + setDepMeta(newMeta); + }; + fetchMeta(); + }, [reviewingProject]); + const fetchQueue = async () => { setLoadingQueue(true); try { @@ -41,17 +117,21 @@ export const AdminPanel: React.FC = ({ currentUser }) => { } }; - if (!currentUser || !isAdmin) { - return ( -
-
- -

Access Denied

-

You do not have permission to view this page.

-
-
- ); - } + const fetchProjectDetails = async (id: string) => { + setLoadingReview(true); + setCurrentStep(0); + setChecklist({}); + setRejectReason(''); + setShowRejectPanel(false); + try { + const res = await api.get(`/admin/projects/${id}/review-details`); + setReviewingProject(res.data); + } catch (e) { + setStatus({ type: 'error', title: 'Error', msg: 'Could not load project details' }); + } finally { + setLoadingReview(false); + } + }; const handleSearch = async (e: React.FormEvent) => { e.preventDefault(); @@ -86,12 +166,21 @@ export const AdminPanel: React.FC = ({ currentUser }) => { const handleToggleAdmin = async () => { if (!foundUser) return; setLoading(true); + const hasAdmin = foundUser.roles && foundUser.roles.includes('ADMIN'); + try { - await api.post(`/admin/users/${foundUser.username}/role`, null, { params: { role: 'ADMIN' } }); - setStatus({ type: 'success', title: 'Role Updated', msg: `Admin role granted to ${foundUser.username}.` }); - const roles = foundUser.roles || []; - if (!roles.includes('ADMIN')) roles.push('ADMIN'); - setFoundUser({...foundUser, roles}); + if (hasAdmin) { + await api.delete(`/admin/users/${foundUser.username}/role`, { params: { role: 'ADMIN' } }); + const roles = foundUser.roles.filter((r: string) => r !== 'ADMIN'); + setFoundUser({...foundUser, roles}); + setStatus({ type: 'info', title: 'Role Updated', msg: `Admin role revoked from ${foundUser.username}.` }); + } else { + await api.post(`/admin/users/${foundUser.username}/role`, null, { params: { role: 'ADMIN' } }); + const roles = foundUser.roles || []; + roles.push('ADMIN'); + setFoundUser({...foundUser, roles}); + setStatus({ type: 'success', title: 'Role Updated', msg: `Admin role granted to ${foundUser.username}.` }); + } } catch (e: any) { setStatus({ type: 'error', title: 'Update Failed', msg: e.response?.data || 'Server error occurred.' }); } finally { @@ -99,11 +188,12 @@ export const AdminPanel: React.FC = ({ currentUser }) => { } }; - const handleApprove = async (id: string) => { - if (!confirm("Are you sure you want to approve this project?")) return; + const handleApprove = async () => { + if (!reviewingProject) return; try { - await api.post(`/projects/${id}/publish`); + await api.post(`/projects/${reviewingProject.mod.id}/publish`); setStatus({ type: 'success', title: 'Approved', msg: 'Project published successfully.' }); + setReviewingProject(null); fetchQueue(); } catch (e: any) { setStatus({ type: 'error', title: 'Error', msg: e.response?.data || 'Failed to approve.' }); @@ -111,181 +201,598 @@ export const AdminPanel: React.FC = ({ currentUser }) => { }; const handleReject = async () => { - if (!rejectingId) return; + if (!reviewingProject) return; try { - await api.post(`/admin/projects/${rejectingId}/reject`, { reason: rejectReason }); + await api.post(`/admin/projects/${reviewingProject.mod.id}/reject`, { reason: rejectReason }); setStatus({ type: 'info', title: 'Rejected', msg: 'Project returned to drafts.' }); - setRejectingId(null); - setRejectReason(''); + setReviewingProject(null); fetchQueue(); } catch (e: any) { setStatus({ type: 'error', title: 'Error', msg: e.response?.data || 'Failed to reject.' }); } }; - return ( -
- {status && setStatus(null)} />} + const toggleCheck = (id: string) => { + setChecklist(prev => ({ ...prev, [id]: !prev[id] })); + }; - {rejectingId && ( -
-
-

Reject Project

-