diff --git a/app/(admin)/admin/processing/events/page.tsx b/app/(admin)/admin/processing/events/page.tsx new file mode 100644 index 00000000..96df9d14 --- /dev/null +++ b/app/(admin)/admin/processing/events/page.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { Filter, ScrollText } from "lucide-react"; +import Link from "next/link"; +import { useMemo, useState } from "react"; + +import PrettyDate from "@/components/General/PrettyDate"; +import PrettyHeader from "@/components/General/PrettyHeader"; +import { SmallUserElement } from "@/components/SmallUserElement"; +import Spinner from "@/components/Spinner"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { MultiSelect } from "@/components/ui/multi-select"; +import { useScoreProcessingEvents } from "@/lib/hooks/api/score-processing/useScoreProcessingEvents"; +import { ScoreProcessingEventType } from "@/lib/types/api"; + +const PAGE_SIZE = 25; + +export default function Page() { + const [page, setPage] = useState(1); + const [showFilters, setShowFilters] = useState(false); + const [eventTypes, setEventTypes] = useState([]); + + const { data, isLoading } = useScoreProcessingEvents( + useMemo(() => ({ types: eventTypes, page, limit: PAGE_SIZE }), [eventTypes, page]), + { refreshInterval: 0, revalidateOnFocus: false, keepPreviousData: true }, + ); + + const events = data?.events ?? []; + const totalCount = data?.total_count ?? 0; + const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)); + + return ( +
+ } /> + +
+

+ {totalCount} + {" "} + event(s) +

+ +
+ +
+ + + + ({ + value: option, + label: option, + }))} + defaultValue={eventTypes} + placeholder="All event types" + onValueChange={(values) => { + setEventTypes(values); + setPage(1); + }} + /> + + +
+ +
+ {isLoading && events.length === 0 + ? ( + + + + + + ) + : events.length > 0 + ? ( + events.map(event => ( + + +
+
+ {event.event_type} + {event.executor + ? ( + + ) + : Server} + {event.score_id != null && ( + + {`Score #${event.score_id}`} + + )} + {event.task_id != null && ( + + {`Task #${event.task_id}`} + + )} +
+ {event.json_data && ( +
+                            {event.json_data}
+                          
+ )} +
+ +
+
+ )) + ) + : ( + + + +

No processing events found.

+
+
+ )} +
+ +
+ + + {`Page ${page} of ${totalPages}`} + + +
+
+ ); +} diff --git a/app/(admin)/admin/processing/scores/page.tsx b/app/(admin)/admin/processing/scores/page.tsx new file mode 100644 index 00000000..293448c2 --- /dev/null +++ b/app/(admin)/admin/processing/scores/page.tsx @@ -0,0 +1,281 @@ +"use client"; + +import { Filter, ListChecks, RefreshCw, Search } from "lucide-react"; +import type { ComponentProps } from "react"; +import { useState } from "react"; + +import { AddScoreProcessingDialog } from "@/components/Admin/ScoreProcessing/AddScoreProcessingDialog"; +import { ScoreProcessingTaskCard } from "@/components/Admin/ScoreProcessing/ScoreProcessingTaskCard"; +import { ScoreProcessingTaskFiltersCard } from "@/components/Admin/ScoreProcessing/ScoreProcessingTaskFiltersCard"; +import PrettyHeader from "@/components/General/PrettyHeader"; +import Spinner from "@/components/Spinner"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { useScoreProcessingStats } from "@/lib/hooks/api/score-processing/useScoreProcessingStats"; +import type { ScoreProcessingTaskFilters } from "@/lib/hooks/api/score-processing/useScoreProcessingTasks"; +import { useScoreProcessingTasks } from "@/lib/hooks/api/score-processing/useScoreProcessingTasks"; +import useDebounce from "@/lib/hooks/useDebounce"; +import type { ScoreProcessingTaskResponse } from "@/lib/types/api"; +import { SecondsToString } from "@/lib/utils/secondsTo"; +import { tryParseNumber } from "@/lib/utils/type.util"; + +const PAGE_SIZE = 20; + +export default function Page() { + const [searchByScoreIdQuery, setSearchByScoreIdQuery] = useState(""); + const searchByScoreIdValue = useDebounce(searchByScoreIdQuery, 400); + + const [searchByTaskIdQuery, setSearchByTaskIdQuery] = useState(""); + const searchByTaskIdValue = useDebounce(searchByTaskIdQuery, 400); + + const [showFilters, setShowFilters] = useState(false); + const [filters, setFilters] = useState({}); + + const scoreIdFilter = tryParseNumber(searchByScoreIdValue) ?? null; + const taskIdFilter = tryParseNumber(searchByTaskIdValue) ?? null; + + const combinedFilters = { + ...filters, + ...(scoreIdFilter ? { score_id: scoreIdFilter } : {}), + ...(taskIdFilter ? { task_id: taskIdFilter } : {}), + }; + + const { data, size, setSize, isLoading, mutate } = useScoreProcessingTasks(combinedFilters, PAGE_SIZE, { + refreshInterval: 10_000, + revalidateOnFocus: false, + keepPreviousData: true, + }); + + const { data: stats, mutate: mutateStats } = useScoreProcessingStats({ + refreshInterval: 10_000, + revalidateOnFocus: false, + keepPreviousData: true, + }); + + const tasks = data?.flatMap(page => page.tasks) ?? []; + const totalCount = data?.find(item => item.total_count !== undefined)?.total_count ?? 0; + + const isLoadingMore = Boolean(isLoading || (size > 0 && data && data[size - 1] === undefined)); + + const activeFilterCount = Object.values(filters).filter( + value => value != null, + ).length; + + return ( +
+ } /> + + + + { mutate(); mutateStats(); }} + onToggleFilters={() => setShowFilters(!showFilters)} + searchByScoreIdQuery={searchByScoreIdQuery} + searchByTaskIdQuery={searchByTaskIdQuery} + /> + + + +

+ {totalCount} + {" "} + task(s) +

+ + setSize(size + 1)} + onTaskChanged={mutate} + tasks={tasks} + totalCount={totalCount} + /> +
+ ); +} + +function ScoreProcessingStats({ stats }: { stats: ReturnType["data"] }) { + if (!stats) + return null; + + const values = [ + { label: "In queue", value: stats.pending.toLocaleString() }, + { label: "Processing", value: stats.processing.toLocaleString() }, + { label: "To process", value: (stats.pending + stats.processing).toLocaleString() }, + { label: "Failed", value: stats.failed.toLocaleString() }, + { + label: "Est. time to clear", + value: + stats.pending > 0 && stats.estimated_pending_completion_seconds != null + ? SecondsToString(stats.estimated_pending_completion_seconds) + : "—", + }, + ]; + + return ( +
+ {values.map(stat => ( +
+
{stat.label}
+
{stat.value}
+
+ ))} +
+ ); +} + +function ScoreProcessingToolbar({ + activeFilterCount, + onFilterByScoreIdChange, + onFilterByTaskIdChange, + onRefresh, + onToggleFilters, + searchByScoreIdQuery, + searchByTaskIdQuery, +}: { + activeFilterCount: number; + onFilterByScoreIdChange: (value: string) => void; + onFilterByTaskIdChange: (value: string) => void; + onRefresh: () => void; + onToggleFilters: () => void; + searchByScoreIdQuery: string; + searchByTaskIdQuery: string; +}) { + return ( +
+ + +
+ + + onRefresh()} /> +
+
+ ); +} + +function ScoreProcessingSearchInput({ + inputMode, + onChange, + placeholder, + value, +}: { + inputMode?: ComponentProps["inputMode"]; + onChange: (value: string) => void; + placeholder: string; + value: string; +}) { + return ( +
+ + onChange(event.target.value)} + /> +
+ ); +} + +function ScoreProcessingFiltersPanel({ + filters, + isLoading, + onApply, + showFilters, +}: { + filters: ScoreProcessingTaskFilters; + isLoading: boolean; + onApply: (filters: ScoreProcessingTaskFilters) => void; + showFilters: boolean; +}) { + return ( +
+ +
+ ); +} + +function ScoreProcessingTaskList({ + isLoading, + isLoadingMore, + onLoadMore, + onTaskChanged, + tasks, + totalCount, +}: { + isLoading: boolean; + isLoadingMore: boolean; + onLoadMore: () => void; + onTaskChanged: () => void; + tasks: ScoreProcessingTaskResponse[]; + totalCount: number; +}) { + if (isLoading && tasks.length === 0) { + return ( + + + + + + ); + } + + if (tasks.length === 0) { + return ( + + + +

No score processing tasks found.

+
+
+ ); + } + + return ( + <> +
+ {tasks.map(task => ( + + ))} +
+ + {tasks.length < totalCount && ( +
+ +
+ )} + + ); +} diff --git a/app/(admin)/admin/scores/[id]/page.tsx b/app/(admin)/admin/scores/[id]/page.tsx new file mode 100644 index 00000000..88fb33a4 --- /dev/null +++ b/app/(admin)/admin/scores/[id]/page.tsx @@ -0,0 +1,321 @@ +"use client"; + +import { ExternalLink, LucideHistory, LucideScanSearch, Play, Square } from "lucide-react"; +import Link from "next/link"; +import { use, useState } from "react"; + +import PrettyDate from "@/components/General/PrettyDate"; +import PrettyHeader from "@/components/General/PrettyHeader"; +import { SmallUserElement } from "@/components/SmallUserElement"; +import Spinner from "@/components/Spinner"; +import { Tooltip } from "@/components/Tooltip"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardTitle } from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table"; +import { useToast } from "@/hooks/use-toast"; +import { useBeatmap } from "@/lib/hooks/api/beatmap/useBeatmap"; +import { + useCancelScoreProcessingTask, + useCreateScoreProcessingTask, +} from "@/lib/hooks/api/score-processing/useScoreProcessingActions"; +import { useScoreProcessingEvents } from "@/lib/hooks/api/score-processing/useScoreProcessingEvents"; +import { useScoreProcessingPreview } from "@/lib/hooks/api/score-processing/useScoreProcessingPreview"; +import type { AdminScoreResponse, BeatmapResponse } from "@/lib/types/api"; +import { ScoreProcessingStatus, ScoreTaskType } from "@/lib/types/api"; +import { getStatusBadgeColor } from "@/lib/utils/getStatusBadgeColor"; +import numberWith from "@/lib/utils/numberWith"; +import { tryParseNumber } from "@/lib/utils/type.util"; + +export default function Page(props: { params: Promise<{ id: string }> }) { + const params = use(props.params); + const scoreId = tryParseNumber(params.id) ?? 0; + + const { data: preview, isLoading, mutate } = useScoreProcessingPreview(scoreId); + const { data: historyData, mutate: mutateHistory } = useScoreProcessingEvents( + { scoreId, limit: 20 }, + { refreshInterval: 0, revalidateOnFocus: false }, + ); + + const score = preview?.score.score; + const beatmapQuery = useBeatmap(score?.beatmap_id ?? null); + const beatmap = beatmapQuery.data; + + const refresh = () => { + mutate(); + mutateHistory(); + }; + + if (isLoading || !preview || !score) { + return ( +
+ +
+ ); + } + + const adminScore = preview.score; + + return ( +
+ } /> + + + +
+ + Score details +
+
+ +
+ + + +
+ + Score utilities +
+
+ +
+ + + +
+ + Score processing history +
+
+ +
+
+ ); +} + +function ScoreUtilities({ scoreId }: { scoreId: number }) { + return ( +
+ + +
+ + + + {/* TODO: Admin replay download */} + +
+
+ ); +} + +function ScoreProcessingHistory({ scoreId, preview, historyData, refresh }: { scoreId: number; preview: NonNullable["data"]>; historyData: ReturnType["data"]; refresh: () => void }) { + const [action, setAction] = useState(ScoreTaskType.RECALCULATION); + + const { toast } = useToast(); + + const handleRun = async () => { + try { + await createTask({ score_id: scoreId, action }); + toast({ title: "Task queued", description: `Queued ${action} for score #${scoreId}.` }); + refresh(); + } + catch (error) { + toast({ title: "Could not queue task", description: (error as Error).message, variant: "destructive" }); + } + }; + + const handleStop = async () => { + if (!preview?.active_task) + return; + + try { + await cancelTask(); + toast({ title: "Task stopped", description: `Task #${preview.active_task.id} was cancelled.` }); + refresh(); + } + catch (error) { + toast({ title: "Could not stop task", description: (error as Error).message, variant: "destructive" }); + } + }; + + const { trigger: createTask, isMutating: isCreating } = useCreateScoreProcessingTask(); + const { trigger: cancelTask, isMutating: isCancelling } = useCancelScoreProcessingTask(preview?.active_task?.id ?? 0); + + const activeTask = preview.active_task; + const history = historyData?.events ?? []; + + return ( +
+
+ + +
+ + {activeTask && ( +
+ Active task: + {activeTask.status} + {activeTask.task_type} + +
+ )} + +

Processing history

+ + {history.length > 0 + ? ( + history.map(event => ( +
+ {event.event_type} + {event.executor + ? ( + + {event.executor.username} + + ) + : Server} + +
+ )) + ) + :

No processing history for this score.

} + +
+ ); +} +function ScoreDetails({ scoreWithDetails, beatmap }: { scoreWithDetails: AdminScoreResponse; beatmap?: BeatmapResponse }) { + const { score } = scoreWithDetails; + + const rowDataGeneric = [ + { label: "Score ID", value: score.id }, + { label: "Beatmap", value: beatmap ? {beatmap.artist} - {beatmap.title} : `Beatmap #${score.beatmap_id}` }, + { label: "User", value: }, + { label: "Submission Status", value: scoreWithDetails.submission_status }, + { label: "Beatmap Status", value: scoreWithDetails.beatmap_status }, + { label: "When Submitted", value: }, + { label: "Replay Available", value: score.has_replay ? "Yes" : "No" }, + ]; + + const rowDataScore = [ + { label: "Mode (extended)", value: score.game_mode_extended }, + { label: "Mods", value: score.mods }, + { label: "Total Score", value: numberWith(score.total_score, ",") }, + { label: "Hit Counts", value: ( +
+
+ 300: + {score.count_300} +
+
+ Geki: + {score.count_geki} +
+
+ 100: + {score.count_100} +
+
+ Katu: + {score.count_katu} +
+
+ 50: + {score.count_50} +
+
+ Miss: + {score.count_miss} +
+
+ ) }, + { label: "Max Combo", value: `${score.max_combo}x / ${beatmap?.max_combo ?? 0}x` }, + { label: "Grade", value: score.grade }, + { label: "PP", value: {score.performance_points.toFixed(2)}pp }, + { label: "Accuracy", value: {score.accuracy.toFixed(2)}% }, + { label: "Score Hash", value: scoreWithDetails.score_hash }, + ]; + + return ( +
+ General info + + + {rowDataGeneric.map((row, index) => ( + // eslint-disable-next-line @eslint-react/no-array-index-key -- used with header + + {row.label} + {row.value} + + ))} + +
+ Score stats + + + {rowDataScore.map((row, index) => ( + // eslint-disable-next-line @eslint-react/no-array-index-key -- used with header + + {row.label} + {row.value} + + ))} + +
+
+ ); +} diff --git a/app/(admin)/admin/users/[id]/edit/components/Tabs/AdminUserEditScores/AdminUserEditScores.tsx b/app/(admin)/admin/users/[id]/edit/components/Tabs/AdminUserEditScores/AdminUserEditScores.tsx new file mode 100644 index 00000000..d042a56f --- /dev/null +++ b/app/(admin)/admin/users/[id]/edit/components/Tabs/AdminUserEditScores/AdminUserEditScores.tsx @@ -0,0 +1,308 @@ +"use client"; + +import { Filter } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { useAdminScoreColumns } from "@/app/(admin)/admin/users/[id]/edit/components/Tabs/AdminUserEditScores/components/AdminScoreColumns"; +import { AdminScoreDataTable } from "@/app/(admin)/admin/users/[id]/edit/components/Tabs/AdminUserEditScores/components/AdminScoreDataTable"; +import { ScoreFiltersCard } from "@/components/Admin/ScoreProcessing/ScoreFiltersCard"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useToast } from "@/hooks/use-toast"; +import { + useBulkScoreProcessing, + useBulkScoreProcessingByFilter, +} from "@/lib/hooks/api/score-processing/useScoreProcessingActions"; +import type { AdminUserScoresParams } from "@/lib/hooks/api/user/useAdminUserScores"; +import { useAdminUserScores } from "@/lib/hooks/api/user/useAdminUserScores"; +import type { UserSensitiveResponse } from "@/lib/types/api"; +import { ScoreTaskType } from "@/lib/types/api"; + +const PAGE_SIZE = 25; +const MAX_BULK_IDS = 100; + +export default function AdminUserEditScores({ user }: { user: UserSensitiveResponse }) { + const { toast } = useToast(); + const userId = user.user_id; + const columns = useAdminScoreColumns(); + + const [filters, setFilters] = useState({}); + const [showFilters, setShowFilters] = useState(false); + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: PAGE_SIZE }); + const [selectedIds, setSelectedIds] = useState([]); + const [bulkAction, setBulkAction] = useState(ScoreTaskType.RECALCULATION); + + const queryParams = useMemo( + () => ({ + mode: filters.mode ?? undefined, + mods: filters.mods ?? undefined, + submission_status: filters.submission_status ?? undefined, + beatmap_status: filters.beatmap_status ?? undefined, + submitted_from: filters.submitted_from ?? undefined, + submitted_to: filters.submitted_to ?? undefined, + sort: filters.sort ?? undefined, + }), + [filters], + ); + + const { data, isLoading, mutate } = useAdminUserScores( + userId, + queryParams, + pagination.pageIndex + 1, + pagination.pageSize, + { + revalidateOnFocus: false, + keepPreviousData: true, + }, + ); + + const scores = useMemo(() => data?.scores ?? [], [data?.scores]); + const totalCount = data?.total_count ?? 0; + + useEffect(() => { + setPagination({ pageIndex: 0, pageSize: PAGE_SIZE }); + setSelectedIds([]); + }, [queryParams]); + + const { trigger: bulkByIds, isMutating: isBulkByIds } = useBulkScoreProcessing(); + const { trigger: bulkByFilter, isMutating: isBulkByFilter } = useBulkScoreProcessingByFilter(); + + const applyBulk = async () => { + try { + if (selectedIds.length === 0) { + toast({ + title: "Nothing selected", + description: "Select scores first.", + variant: "destructive", + }); + return; + } + + if (selectedIds.length > MAX_BULK_IDS) { + toast({ + title: "Too many selected", + description: `Select up to ${MAX_BULK_IDS} scores, or use "Apply to all matching".`, + variant: "destructive", + }); + return; + } + + const result = await bulkByIds({ score_ids: selectedIds, action: bulkAction }); + toast({ + title: "Bulk queued", + description: `Queued ${result.queued}, skipped ${result.skipped}.`, + }); + + setSelectedIds([]); + mutate(); + } + catch (error) { + toast({ + title: "Bulk action failed", + description: (error as Error).message, + variant: "destructive", + }); + } + }; + + const handleSelectionChange = useCallback((ids: number[]) => { + setSelectedIds(ids); + }, []); + + const handleApplyFilters = useCallback((next: AdminUserScoresParams) => { + setFilters(next); + setPagination({ pageIndex: 0, pageSize: PAGE_SIZE }); + }, []); + + const applyBulkAllMatching = useCallback(async () => { + try { + await bulkByFilter({ + action: bulkAction, + user_id: userId, + ...queryParams, + }); + toast({ + title: "Bulk queued", + description: `Queued ${bulkAction} for all ${totalCount} matching scores (runs in background).`, + }); + setSelectedIds([]); + mutate(); + } + catch (error) { + toast({ + title: "Bulk action failed", + description: (error as Error).message, + variant: "destructive", + }); + } + }, [bulkAction, bulkByFilter, mutate, queryParams, toast, totalCount, userId]); + + return ( +
+
+

+ {totalCount} + {" "} + score(s) +

+ +
+ + + + + + +
+ ); +} + +function FilterPanel({ + showFilters, + filters, + isLoading, + onApplyFilters, +}: { + showFilters: boolean; + filters: AdminUserScoresParams; + isLoading: boolean; + onApplyFilters: (filters: AdminUserScoresParams) => void; +}) { + return ( +
+ +
+ ); +} + +function BulkActionToolbar({ + bulkAction, + onBulkActionChange, + onApplyBulk, + onApplyBulkAllMatching, + selectedCount, + totalCount, + pageSize, + isBulkLoading, +}: { + bulkAction: ScoreTaskType; + onBulkActionChange: (action: ScoreTaskType) => void; + onApplyBulk: () => Promise; + onApplyBulkAllMatching: () => Promise; + selectedCount: number; + totalCount: number; + pageSize: number; + isBulkLoading: boolean; +}) { + const [confirmDialogOpen, setConfirmDialogOpen] = useState(false); + const [pendingAction, setPendingAction] = useState<"single" | "all" | null>(null); + + const showAllMatchingBtn = selectedCount === pageSize && pageSize > 0 && totalCount > pageSize; + + const handleConfirm = async () => { + setConfirmDialogOpen(false); + if (pendingAction === "single") { + await onApplyBulk(); + } + else if (pendingAction === "all") { + await onApplyBulkAllMatching(); + } + setPendingAction(null); + }; + + const handleOpenDialog = (action: "single" | "all") => { + setPendingAction(action); + setConfirmDialogOpen(true); + }; + + return ( +
+ + {selectedCount} + {" "} + selected + + + + {showAllMatchingBtn && ( + + )} + + + + Confirm Bulk Action + + {pendingAction === "all" + ? `Are you sure you want to apply "${bulkAction}" to all ${totalCount} matching scores?` + : `Are you sure you want to apply "${bulkAction}" to ${selectedCount} selected score(s)?`} + + +
+ Cancel + + Confirm + +
+
+
+
+ ); +} diff --git a/app/(admin)/admin/users/[id]/edit/components/Tabs/AdminUserEditScores/components/AdminScoreColumns.tsx b/app/(admin)/admin/users/[id]/edit/components/Tabs/AdminUserEditScores/components/AdminScoreColumns.tsx new file mode 100644 index 00000000..5f8d5475 --- /dev/null +++ b/app/(admin)/admin/users/[id]/edit/components/Tabs/AdminUserEditScores/components/AdminScoreColumns.tsx @@ -0,0 +1,120 @@ +"use client"; + +import type { ColumnDef } from "@tanstack/react-table"; +import { Check, X } from "lucide-react"; +import Link from "next/link"; +import { Suspense, useMemo } from "react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import type { AdminScoreResponse } from "@/lib/types/api"; +import { getGradeColor } from "@/lib/utils/getGradeColor"; +import numberWith from "@/lib/utils/numberWith"; + +import BeatmapCellContent from "./BeatmapCellContent"; + +export function useAdminScoreColumns(): Array> { + return useMemo( + () => [ + { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + /> + ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "score.id", + header: "Score ID", + cell: ({ row }) => #{row.original.score.id}, + }, + { + accessorKey: "score.grade", + header: "Grade", + cell: ({ row }) => ( + + {row.original.score.grade} + + ), + }, + { + accessorKey: "score.is_passed", + header: "Pass", + cell: ({ row }) => row.original.score.is_passed + ? + : , + }, + { + id: "preview", + header: "", + cell: ({ row }) => ( + Loading...}> + + + ), + }, + { + accessorKey: "score.total_score", + header: "Score", + cell: ({ row }) => {numberWith(row.original.score.total_score, ",")}, + }, + { + accessorKey: "score.performance_points", + header: "PP", + cell: ({ row }) => {row.original.score.performance_points.toFixed(2)}, + }, + { + accessorKey: "score.accuracy", + header: "Acc", + cell: ({ row }) => {row.original.score.accuracy.toFixed(2)}%, + }, + { + accessorKey: "score.mods", + header: "Mods", + cell: ({ row }) => ( + + {row.original.score.mods || "-"} + + ), + }, + { + accessorKey: "submission_status", + header: "Status", + cell: ({ row }) => ( + + {row.original.submission_status} + + ), + }, + { + id: "actions", + header: "", + cell: ({ row }) => { + const scoreId = row.original.score.id; + return ( + + ); + }, + }, + ], + [], + ); +} diff --git a/app/(admin)/admin/users/[id]/edit/components/Tabs/AdminUserEditScores/components/AdminScoreDataTable.tsx b/app/(admin)/admin/users/[id]/edit/components/Tabs/AdminUserEditScores/components/AdminScoreDataTable.tsx new file mode 100644 index 00000000..39f6cb91 --- /dev/null +++ b/app/(admin)/admin/users/[id]/edit/components/Tabs/AdminUserEditScores/components/AdminScoreDataTable.tsx @@ -0,0 +1,237 @@ +"use client"; + +import type { + ColumnDef, + OnChangeFn, + PaginationState, + RowSelectionState, + SortingState, + VisibilityState, +} from "@tanstack/react-table"; +import { + flexRender, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { + ChevronLeft, + ChevronRight, + ChevronsLeft, + ChevronsRight, +} from "lucide-react"; +import { useEffect, useState } from "react"; + +import Spinner from "@/components/Spinner"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { AdminScoreResponse } from "@/lib/types/api"; + +interface AdminScoreDataTableProps { + columns: Array>; + data: AdminScoreResponse[]; + totalCount: number; + isLoading: boolean; + pagination: { + pageIndex: number; + pageSize: number; + }; + setPagination: OnChangeFn; + onSelectionIdsChange: (ids: number[]) => void; +} + +export function AdminScoreDataTable({ + columns, + data, + totalCount, + isLoading, + pagination, + setPagination, + onSelectionIdsChange, +}: AdminScoreDataTableProps) { + const [sorting, setSorting] = useState([]); + const [columnVisibility, setColumnVisibility] = useState({}); + const [rowSelection, setRowSelection] = useState({}); + + const pageCount = Math.max(1, Math.ceil(totalCount / pagination.pageSize)); + + const table = useReactTable({ + data, + columns, + manualPagination: true, + pageCount, + getRowId: row => row.score.id.toString(), + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + onSortingChange: setSorting, + getSortedRowModel: getSortedRowModel(), + onColumnVisibilityChange: setColumnVisibility, + onRowSelectionChange: setRowSelection, + state: { + pagination, + sorting, + columnVisibility, + rowSelection, + }, + }); + + useEffect(() => { + const selected = table.getSelectedRowModel().rows.map(row => row.original.score.id); + onSelectionIdsChange(selected); + }, [onSelectionIdsChange, rowSelection, table]); + + useEffect(() => { + setRowSelection({}); + }, [data]); + + const selectedRows = table.getFilteredSelectedRowModel().rows; + + return ( +
+
+ {isLoading && data.length === 0 + ? ( + + + + + + ) + : ( + + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length + ? ( + table.getRowModel().rows.map(row => ( + + {row.getVisibleCells().map(cell => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) + : ( + + + No scores found. + + + )} + +
+ )} +
+ + +
+ ); +} + +function AdminScorePagination({ + table, + totalCount, + pagination, + setPagination, + selectedCount, +}: { + table: ReturnType>; + totalCount: number; + pagination: { pageIndex: number; pageSize: number }; + setPagination: OnChangeFn; + selectedCount: number; +}) { + const pageCount = Math.max(1, Math.ceil(totalCount / pagination.pageSize)); + + return ( +
+
+ + per page +
+ +
+ {totalCount} + {" "} + total + {selectedCount > 0 && ` • ${selectedCount} selected`} +
+ +
+ + {`Page ${pagination.pageIndex + 1} of ${pageCount}`} + + + + + +
+
+ ); +} diff --git a/app/(admin)/admin/users/[id]/edit/components/Tabs/AdminUserEditScores/components/BeatmapCellContent.tsx b/app/(admin)/admin/users/[id]/edit/components/Tabs/AdminUserEditScores/components/BeatmapCellContent.tsx new file mode 100644 index 00000000..011128be --- /dev/null +++ b/app/(admin)/admin/users/[id]/edit/components/Tabs/AdminUserEditScores/components/BeatmapCellContent.tsx @@ -0,0 +1,63 @@ +"use client"; + +import Image from "next/image"; +import Link from "next/link"; + +import BeatmapStatusIcon from "@/components/BeatmapStatus"; +import PrettyDate from "@/components/General/PrettyDate"; +import { useBeatmap } from "@/lib/hooks/api/beatmap/useBeatmap"; +import { BeatmapStatusWeb } from "@/lib/types/api"; + +export default function BeatmapCellContent({ + beatmapId, + whenSubmitted, +}: { + beatmapId: number; + whenSubmitted: string; +}) { + const { data: beatmap } = useBeatmap(beatmapId); + + return ( +
+ {beatmap + ? ( + {`${beatmap.artist} + ) + :
} + +
+ {beatmap?.status && beatmap.status !== BeatmapStatusWeb.UNKNOWN && ( + + + + )} + {beatmap + ? ( + + {beatmap.artist} - {beatmap.title} + + ) + : ( + + {`Beatmap #${beatmapId}`} + + )} + +
+ Date played: + +
+
+
+ ); +} diff --git a/app/(admin)/admin/users/[id]/edit/page.tsx b/app/(admin)/admin/users/[id]/edit/page.tsx index 8ab864f5..ae5c116f 100644 --- a/app/(admin)/admin/users/[id]/edit/page.tsx +++ b/app/(admin)/admin/users/[id]/edit/page.tsx @@ -1,11 +1,12 @@ "use client"; -import { Activity, FileText, User } from "lucide-react"; +import { FileText, Trophy, User } from "lucide-react"; import { usePathname, useSearchParams } from "next/navigation"; import { use, useCallback, useEffect, useRef, useState } from "react"; import AdminUserEditEvent from "@/app/(admin)/admin/users/[id]/edit/components/Tabs/AdminUserEditEvents"; import AdminUserEditGeneral from "@/app/(admin)/admin/users/[id]/edit/components/Tabs/AdminUserEditGeneral"; +import AdminUserEditScores from "@/app/(admin)/admin/users/[id]/edit/components/Tabs/AdminUserEditScores/AdminUserEditScores"; import PrettyHeader from "@/components/General/PrettyHeader"; import Spinner from "@/components/Spinner"; import { @@ -18,9 +19,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; -import { Card, CardContent } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { WorkInProgress } from "@/components/WorkInProgress"; import { useAdminUserSensitive } from "@/lib/hooks/api/user/useAdminUserEdit"; export default function Page({ params }: { params: Promise<{ id: string }> }) { @@ -35,7 +34,7 @@ export default function Page({ params }: { params: Promise<{ id: string }> }) { = useState(false); const [showEventsWarning, setShowEventsWarning] = useState(false); const [pendingTab, setPendingTab] = useState(null); - const hasCheckedInitialTab = useRef(false); + const hasCheckedInitialTabRef = useRef(false); const { data: user, isLoading } = useAdminUserSensitive(userId); @@ -63,13 +62,13 @@ export default function Page({ params }: { params: Promise<{ id: string }> }) { useEffect(() => { if ( - !hasCheckedInitialTab.current + !hasCheckedInitialTabRef.current && tab === "events" && !hasAcceptedEventsWarning && !isLoading && user ) { - hasCheckedInitialTab.current = true; + hasCheckedInitialTabRef.current = true; setActiveTab("general"); setPendingTab("events"); setShowEventsWarning(true); @@ -148,14 +147,14 @@ export default function Page({ params }: { params: Promise<{ id: string }> }) { onValueChange={handleTabChange} className="w-full" > - + General - - - Activity + + + Scores @@ -167,12 +166,8 @@ export default function Page({ params }: { params: Promise<{ id: string }> }) { - - - - - - + + diff --git a/app/(admin)/components/AppSidebar.tsx b/app/(admin)/components/AppSidebar.tsx index 89b2faac..54cb4c75 100644 --- a/app/(admin)/components/AppSidebar.tsx +++ b/app/(admin)/components/AppSidebar.tsx @@ -1,6 +1,6 @@ "use client"; -import { ChevronsUp, Home, Moon, Music2, Sun, Users } from "lucide-react"; +import { ChevronsUp, Home, ListChecks, Moon, Music2, ScrollText, Sun, Users } from "lucide-react"; import Link from "next/link"; import type { SWRInfiniteResponse } from "swr/infinite"; @@ -64,6 +64,22 @@ const actionTabs = [ }, ]; +// It actually requires super user, but we can't check for that in the frontend, so we just check for admin instead. +const processingTabs = [ + { + title: "Score processing", + url: "/admin/processing/scores", + icon: ListChecks, + requires: UserBadge.ADMIN, + }, + { + title: "Processing events", + url: "/admin/processing/events", + icon: ScrollText, + requires: UserBadge.ADMIN, + }, +]; + export function AppSidebar() { const { self } = useSelf(); const requestsQuery = useBeatmapSetGetHypedSets(); @@ -84,6 +100,14 @@ export function AppSidebar() { return !requirements; }); + const processingTabsWithAccess = processingTabs.filter((item) => { + if (!self) + return false; + + const requirements = item.requires && !self.badges.includes(item.requires); + return !requirements; + }); + return ( @@ -128,13 +152,30 @@ export function AppSidebar() { ))} + + {self ? (processingTabsWithAccess.length > 0 ? "Background processing" : "") : null} + + + + {processingTabsWithAccess.map(item => ( + + + + + {item.title} + + + + ))} + + - + Change theme diff --git a/app/(admin)/layout.tsx b/app/(admin)/layout.tsx index 4f8fc1dd..03e6727f 100644 --- a/app/(admin)/layout.tsx +++ b/app/(admin)/layout.tsx @@ -22,8 +22,8 @@ export default function AdminLayout({
- -
{children}
+ {/* Adding bottom padding here so the "Go top" button wouldn't overlap content */} +
{children}
); diff --git a/app/(website)/score/[id]/page.tsx b/app/(website)/score/[id]/page.tsx index 2722654c..3a226e07 100644 --- a/app/(website)/score/[id]/page.tsx +++ b/app/(website)/score/[id]/page.tsx @@ -17,6 +17,7 @@ import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, + DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Separator } from "@/components/ui/separator"; @@ -27,7 +28,7 @@ import { useScore } from "@/lib/hooks/api/score/useScore"; import { useUser } from "@/lib/hooks/api/user/useUser"; import useSelf from "@/lib/hooks/useSelf"; import { useT } from "@/lib/i18n/utils"; -import { BeatmapStatusWeb } from "@/lib/types/api"; +import { BeatmapStatusWeb, UserBadge } from "@/lib/types/api"; import { getBeatmapStarRating } from "@/lib/utils/getBeatmapStarRating"; import { getGradeColor } from "@/lib/utils/getGradeColor"; import { tryParseNumber } from "@/lib/utils/type.util"; @@ -192,7 +193,8 @@ export default function Score(props: { params: Promise<{ id: string }> }) { - + window.open(`/admin/scores/${score.id}`, "_blank")}> + {t("actions.openInAdminPanel")} + {/* TODO: Implement console.log("todo")}> diff --git a/components/Admin/ScoreProcessing/AddScoreProcessingDialog.tsx b/components/Admin/ScoreProcessing/AddScoreProcessingDialog.tsx new file mode 100644 index 00000000..26a08a4a --- /dev/null +++ b/components/Admin/ScoreProcessing/AddScoreProcessingDialog.tsx @@ -0,0 +1,165 @@ +"use client"; + +import { Plus } from "lucide-react"; +import { useState } from "react"; + +import Spinner from "@/components/Spinner"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useToast } from "@/hooks/use-toast"; +import { useCreateScoreProcessingTask } from "@/lib/hooks/api/score-processing/useScoreProcessingActions"; +import { useScoreProcessingPreview } from "@/lib/hooks/api/score-processing/useScoreProcessingPreview"; +import useDebounce from "@/lib/hooks/useDebounce"; +import { ScoreTaskType } from "@/lib/types/api"; +import { getStatusBadgeColor } from "@/lib/utils/getStatusBadgeColor"; +import { tryParseNumber } from "@/lib/utils/type.util"; + +interface AddScoreProcessingDialogProps { + onCreated: () => void; +} + +export function AddScoreProcessingDialog({ onCreated }: AddScoreProcessingDialogProps) { + const { toast } = useToast(); + const [open, setOpen] = useState(false); + const [scoreIdInput, setScoreIdInput] = useState(""); + const [action, setAction] = useState(ScoreTaskType.RECALCULATION); + + const debouncedScoreId = useDebounce(scoreIdInput, 400); + const parsedScoreId = tryParseNumber(debouncedScoreId) ?? null; + + const { data: preview, isLoading, error } = useScoreProcessingPreview(parsedScoreId); + const { trigger: createTask, isMutating } = useCreateScoreProcessingTask(); + + const handleSubmit = async () => { + const scoreId = tryParseNumber(scoreIdInput); + if (!scoreId) { + toast({ title: "Invalid score id", description: "Enter a valid score id.", variant: "destructive" }); + return; + } + + try { + await createTask({ score_id: scoreId, action }); + toast({ title: "Task queued", description: `Queued ${action} for score #${scoreId}.` }); + onCreated(); + setOpen(false); + setScoreIdInput(""); + } + catch (createError) { + toast({ title: "Could not queue task", description: (createError as Error).message, variant: "destructive" }); + } + }; + + return ( + + + + + + + Queue score processing + + Enter a score id, review the preview, then choose an action to queue. + + + +
+
+ + setScoreIdInput(event.target.value)} + /> +
+ + {parsedScoreId && ( +
+ {isLoading + ? ( +
+ +
+ ) + : error + ?

Score not found.

+ : preview + ? ( +
+

+ Score # + {preview.score.score.id} + {" · "} + {preview.score.score.user?.username ?? "Unknown"} +

+

+ {Math.round(preview.score.score.performance_points)} + pp + {" · "} + {preview.score.submission_status} + {" · "} + {preview.score.beatmap_status} +

+ {preview.active_task && ( + + Active: + {" "} + {preview.active_task.task_type} + {" "} + ( + {preview.active_task.status} + ) + + )} +
+ ) + : null} +
+ )} + +
+ + +
+
+ + + + +
+
+ ); +} diff --git a/components/Admin/ScoreProcessing/ScoreFiltersCard.tsx b/components/Admin/ScoreProcessing/ScoreFiltersCard.tsx new file mode 100644 index 00000000..47c1dc84 --- /dev/null +++ b/components/Admin/ScoreProcessing/ScoreFiltersCard.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { useCallback, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { MultiSelect } from "@/components/ui/multi-select"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { AdminUserScoresParams } from "@/lib/hooks/api/user/useAdminUserScores"; +import { BeatmapStatus, GameMode, Mods, ScoreSortType, SubmissionStatus } from "@/lib/types/api"; + +interface ScoreFiltersCardProps { + value: AdminUserScoresParams; + onApply: (filters: AdminUserScoresParams) => void; + isLoading?: boolean; +} + +const ANY_VALUE = "any"; + +export function ScoreFiltersCard({ + value, + onApply, + isLoading, +}: ScoreFiltersCardProps) { + const [draft, setDraft] = useState(value); + + const update = useCallback( + (key: Key, next: AdminUserScoresParams[Key]) => { + setDraft(previous => ({ ...previous, [key]: next })); + }, + [], + ); + + const handleApply = useCallback(() => onApply(draft), [draft, onApply]); + + return ( + + +
+ + +
+ +
+ + ({ label: option, value: option }))} + defaultValue={draft.mods ?? []} + placeholder="Any mods" + onValueChange={selected => update("mods", selected.length > 0 ? selected as Mods[] : undefined)} + /> +
+ +
+ + +
+ +
+ + +
+ +
+ + update("submitted_from", event.target.value || undefined)} + /> +
+ +
+ + update("submitted_to", event.target.value || undefined)} + /> +
+ +
+ + +
+ +
+ +
+
+
+ ); +} diff --git a/components/Admin/ScoreProcessing/ScoreProcessingTaskCard.tsx b/components/Admin/ScoreProcessing/ScoreProcessingTaskCard.tsx new file mode 100644 index 00000000..3aca2bb7 --- /dev/null +++ b/components/Admin/ScoreProcessing/ScoreProcessingTaskCard.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { ExternalLink, RotateCcw, Square } from "lucide-react"; +import Link from "next/link"; + +import PrettyDate from "@/components/General/PrettyDate"; +import ImageWithFallback from "@/components/ImageWithFallback"; +import { SmallUserElement } from "@/components/SmallUserElement"; +import { Tooltip } from "@/components/Tooltip"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { useToast } from "@/hooks/use-toast"; +import { useBeatmap } from "@/lib/hooks/api/beatmap/useBeatmap"; +import { + useCancelScoreProcessingTask, + useRequeueScoreProcessingTask, +} from "@/lib/hooks/api/score-processing/useScoreProcessingActions"; +import type { ScoreProcessingTaskResponse } from "@/lib/types/api"; +import { ScoreProcessingStatus } from "@/lib/types/api"; +import { getGradeColor } from "@/lib/utils/getGradeColor"; +import { getStatusBadgeColor } from "@/lib/utils/getStatusBadgeColor"; + +interface ScoreProcessingTaskCardProps { + task: ScoreProcessingTaskResponse; + onChanged: () => void; +} + +export function ScoreProcessingTaskCard({ task, onChanged }: ScoreProcessingTaskCardProps) { + const { toast } = useToast(); + const score = task.score?.score; + const beatmapQuery = useBeatmap(score?.beatmap_id ?? null); + const beatmap = beatmapQuery.data; + + const { trigger: cancelTask, isMutating: isCancelling } = useCancelScoreProcessingTask(task.id); + const { trigger: requeueTask, isMutating: isRequeueing } = useRequeueScoreProcessingTask(task.id); + + const handleStop = async () => { + try { + await cancelTask(); + toast({ title: "Task stopped", description: `Task #${task.id} was cancelled.` }); + onChanged(); + } + catch (error) { + toast({ title: "Could not stop task", description: (error as Error).message, variant: "destructive" }); + } + }; + + const handleRequeue = async () => { + try { + await requeueTask(); + toast({ title: "Task requeued", description: `Task #${task.id} was requeued.` }); + onChanged(); + } + catch (error) { + toast({ title: "Could not requeue task", description: (error as Error).message, variant: "destructive" }); + } + }; + + const isProcessing = task.status === ScoreProcessingStatus.PROCESSING; + const isPending = task.status === ScoreProcessingStatus.PENDING; + const isFailed = task.status === ScoreProcessingStatus.FAILED; + + return ( +
+
+ {beatmap && ( + + )} +
+
+ {task.status} + {`ID: ${task.id}`} + {task.score?.score.game_mode_extended} + {task.task_type} + {task.retry_count > 0 && ( + + {`Retries: ${task.retry_count} / Retry: ${task.status === ScoreProcessingStatus.PENDING ? "✔" : "✖"}`} + + )} +
+ +
+ {score && ( + + {score.grade} + + )} +

+ {beatmap ? `${beatmap.artist} - ${beatmap.title}` : `Beatmap #${score?.beatmap_id ?? "?"}`} +

+

+ {score?.mods} +

+
+ +
+ {score?.user && ( + + )} + + Score ID: {task.score_id} + + +
+ + {isFailed && task.error_message && ( +
+ {task.error_code ? `${task.error_code}: ` : ""} + {task.error_message} +
+ )} +
+ +
+ + + {isFailed && ( + + )} + + {isProcessing && ( + + + + + + )} + + {isPending && ( + + )} +
+
+
+ ); +} diff --git a/components/Admin/ScoreProcessing/ScoreProcessingTaskFiltersCard.tsx b/components/Admin/ScoreProcessing/ScoreProcessingTaskFiltersCard.tsx new file mode 100644 index 00000000..96770f40 --- /dev/null +++ b/components/Admin/ScoreProcessing/ScoreProcessingTaskFiltersCard.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useCallback, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { ScoreProcessingTaskFilters } from "@/lib/hooks/api/score-processing/useScoreProcessingTasks"; +import { ScoreProcessingStatus, ScoreTaskType } from "@/lib/types/api"; + +interface ScoreProcessingTaskFiltersCardProps { + value: ScoreProcessingTaskFilters; + onApply: (filters: ScoreProcessingTaskFilters) => void; + isLoading?: boolean; +} + +const ANY_VALUE = "any"; + +export function ScoreProcessingTaskFiltersCard({ + value, + onApply, + isLoading, +}: ScoreProcessingTaskFiltersCardProps) { + const [draft, setDraft] = useState(value); + + const update = useCallback( + (key: Key, next: ScoreProcessingTaskFilters[Key]) => { + setDraft(previous => ({ ...previous, [key]: next })); + }, + [], + ); + + const handleApply = useCallback(() => onApply(draft), [draft, onApply]); + + return ( + + + +
+ + +
+ +
+ + +
+ +
+ +
+
+
+ ); +} diff --git a/lib/hooks/api/score-processing/useScoreProcessingActions.ts b/lib/hooks/api/score-processing/useScoreProcessingActions.ts new file mode 100644 index 00000000..d49e7e3e --- /dev/null +++ b/lib/hooks/api/score-processing/useScoreProcessingActions.ts @@ -0,0 +1,45 @@ +import useSWRMutation from "swr/mutation"; + +import poster from "@/lib/services/poster"; +import type { BulkScoreProcessingByFilterRequest, BulkScoreProcessingRequest, BulkScoreProcessingResultResponse, CreateScoreProcessingTaskRequest, ScoreProcessingTaskResponse } from "@/lib/types/api"; + +export function useCreateScoreProcessingTask() { + return useSWRMutation( + "score-processing/create", + async (_key: string, { arg }: { arg: CreateScoreProcessingTaskRequest }) => { + return poster("score-processing", { json: arg }); + }, + ); +} + +export function useCancelScoreProcessingTask(taskId: number) { + return useSWRMutation( + `score-processing/${taskId}/cancel`, + async (url: string) => poster(url), + ); +} + +export function useRequeueScoreProcessingTask(taskId: number) { + return useSWRMutation( + `score-processing/${taskId}/requeue`, + async (url: string) => poster(url), + ); +} + +export function useBulkScoreProcessing() { + return useSWRMutation( + "score-processing/bulk", + async (_key: string, { arg }: { arg: BulkScoreProcessingRequest }) => { + return poster("score-processing/bulk", { json: arg }); + }, + ); +} + +export function useBulkScoreProcessingByFilter() { + return useSWRMutation( + "score-processing/bulk-by-filter", + async (_key: string, { arg }: { arg: BulkScoreProcessingByFilterRequest }) => { + return poster("score-processing/bulk-by-filter", { json: arg }); + }, + ); +} diff --git a/lib/hooks/api/score-processing/useScoreProcessingEvents.ts b/lib/hooks/api/score-processing/useScoreProcessingEvents.ts new file mode 100644 index 00000000..d23ad7f1 --- /dev/null +++ b/lib/hooks/api/score-processing/useScoreProcessingEvents.ts @@ -0,0 +1,33 @@ +import type { SWRConfiguration } from "swr"; +import useSWR from "swr"; + +import fetcher from "@/lib/services/fetcher"; +import type { EventScoreProcessingListResponse } from "@/lib/types/api"; + +export interface ScoreProcessingEventsParams { + types?: string[] | null; + scoreId?: number | null; + page?: number; + limit?: number; +} + +export function useScoreProcessingEvents( + params: ScoreProcessingEventsParams, + options?: SWRConfiguration, +) { + const queryParams = new URLSearchParams(); + + if (params.page) + queryParams.append("page", params.page.toString()); + if (params.limit) + queryParams.append("limit", params.limit.toString()); + if (params.scoreId) + queryParams.append("score_id", params.scoreId.toString()); + if (params.types && params.types.length > 0) + params.types.forEach(type => queryParams.append("types", type)); + + const queryString = queryParams.toString(); + const endpoint = `score-processing/events${queryString ? `?${queryString}` : ""}`; + + return useSWR(endpoint, fetcher, { ...options }); +} diff --git a/lib/hooks/api/score-processing/useScoreProcessingPreview.ts b/lib/hooks/api/score-processing/useScoreProcessingPreview.ts new file mode 100644 index 00000000..03570713 --- /dev/null +++ b/lib/hooks/api/score-processing/useScoreProcessingPreview.ts @@ -0,0 +1,9 @@ +import useSWR from "swr"; + +import type { ScoreProcessingPreviewResponse } from "@/lib/types/api"; + +export function useScoreProcessingPreview(scoreId: number | null) { + return useSWR( + scoreId && scoreId > 0 ? `score-processing/score/${scoreId}` : null, + ); +} diff --git a/lib/hooks/api/score-processing/useScoreProcessingStats.ts b/lib/hooks/api/score-processing/useScoreProcessingStats.ts new file mode 100644 index 00000000..689a3d17 --- /dev/null +++ b/lib/hooks/api/score-processing/useScoreProcessingStats.ts @@ -0,0 +1,9 @@ +import type { SWRConfiguration } from "swr"; +import useSWR from "swr"; + +import fetcher from "@/lib/services/fetcher"; +import type { ScoreProcessingStatsResponse } from "@/lib/types/api"; + +export function useScoreProcessingStats(options?: SWRConfiguration) { + return useSWR("score-processing/stats", fetcher, { ...options }); +} diff --git a/lib/hooks/api/score-processing/useScoreProcessingTasks.ts b/lib/hooks/api/score-processing/useScoreProcessingTasks.ts new file mode 100644 index 00000000..70dd33ce --- /dev/null +++ b/lib/hooks/api/score-processing/useScoreProcessingTasks.ts @@ -0,0 +1,38 @@ +import type { SWRInfiniteConfiguration } from "swr/infinite"; +import useSWRInfinite from "swr/infinite"; + +import type { GetScoreProcessingData, ScoreProcessingTasksResponse } from "@/lib/types/api"; + +export interface ScoreProcessingTaskFilters extends Omit, "page" | "limit"> {} + +export function useScoreProcessingTasks( + filters: ScoreProcessingTaskFilters, + limit = 20, + options?: SWRInfiniteConfiguration, +) { + const getKey = ( + pageIndex: number, + previousPageData: ScoreProcessingTasksResponse | null, + ) => { + if (previousPageData && previousPageData.tasks.length === 0) + return null; + + const params = new URLSearchParams({ + page: (pageIndex + 1).toString(), + limit: limit.toString(), + }); + + if (filters.status) + params.append("status", filters.status); + if (filters.task_type) + params.append("task_type", filters.task_type); + if (filters.score_id) + params.append("score_id", filters.score_id.toString()); + if (filters.task_id) + params.append("task_id", filters.task_id.toString()); + + return `score-processing?${params.toString()}`; + }; + + return useSWRInfinite(getKey, options); +} diff --git a/lib/hooks/api/user/useAdminUserScores.ts b/lib/hooks/api/user/useAdminUserScores.ts new file mode 100644 index 00000000..00173208 --- /dev/null +++ b/lib/hooks/api/user/useAdminUserScores.ts @@ -0,0 +1,40 @@ +import type { SWRConfiguration } from "swr"; +import useSWR from "swr"; + +import fetcher from "@/lib/services/fetcher"; +import type { AdminScoresResponse, GetUserByIdScoresAdminData } from "@/lib/types/api"; + +export interface AdminUserScoresParams extends Omit, "page" | "limit"> {} + +export function useAdminUserScores( + userId: number, + params: AdminUserScoresParams, + page?: number, + limit?: number, + options?: SWRConfiguration, +) { + const queryParams = new URLSearchParams({ + page: page?.toString() ?? "1", + limit: limit?.toString() ?? "20", + }); + + if (params.mode) + queryParams.append("mode", params.mode); + if (params.mods) + queryParams.append("mods", params.mods.toString()); + if (params.submission_status) + queryParams.append("submission_status", params.submission_status); + if (params.beatmap_status) + queryParams.append("beatmap_status", params.beatmap_status); + if (params.submitted_from) + queryParams.append("submitted_from", params.submitted_from); + if (params.submitted_to) + queryParams.append("submitted_to", params.submitted_to); + if (params.sort) + queryParams.append("sort", params.sort); + + const queryString = queryParams.toString(); + const endpoint = `user/${userId}/scores/admin${queryString ? `?${queryString}` : ""}`; + + return useSWR(endpoint, fetcher, { ...options }); +} diff --git a/lib/i18n/messages/en.json b/lib/i18n/messages/en.json index dbaa0a62..163571c7 100644 --- a/lib/i18n/messages/en.json +++ b/lib/i18n/messages/en.json @@ -1427,6 +1427,10 @@ "openMenu": { "text": "Open menu", "context": "Screen reader text for the dropdown menu button" + }, + "openInAdminPanel": { + "text": "Open in Admin Panel", + "context": "Dropdown menu item text to open score in admin panel" } }, "error": { diff --git a/lib/types/api/types.gen.ts b/lib/types/api/types.gen.ts index 2077dd1a..955c5374 100644 --- a/lib/types/api/types.gen.ts +++ b/lib/types/api/types.gen.ts @@ -1,5 +1,18 @@ // This file is auto-generated by @hey-api/openapi-ts +export type AdminScoreResponse = { + score: ScoreResponse; + submission_status: SubmissionStatus; + beatmap_status: BeatmapStatus; + is_scoreable: boolean; + score_hash?: string | null; +}; + +export type AdminScoresResponse = { + scores: AdminScoreResponse[]; + total_count: number; +}; + export type BeatmapEventResponse = { event_id: number; executor: UserResponse; @@ -88,6 +101,17 @@ export type BeatmapSetsResponse = { total_count?: number | null; }; +export enum BeatmapStatus { + PENDING = "Pending", + NEEDS_UPDATE = "NeedsUpdate", + RANKED = "Ranked", + APPROVED = "Approved", + QUALIFIED = "Qualified", + LOVED = "Loved", + UNKNOWN = "Unknown", + NOT_SUBMITTED = "NotSubmitted", +} + export enum BeatmapStatusWeb { PENDING = "Pending", RANKED = "Ranked", @@ -99,6 +123,27 @@ export enum BeatmapStatusWeb { WIP = "Wip", } +export type BulkScoreProcessingByFilterRequest = { + action: ScoreTaskType; + user_id: number; + mode?: GameMode; + mods?: Mods[] | null; + submission_status?: SubmissionStatus; + beatmap_status?: BeatmapStatus; + submitted_from?: string | null; + submitted_to?: string | null; +}; + +export type BulkScoreProcessingRequest = { + score_ids: number[]; + action: ScoreTaskType; +}; + +export type BulkScoreProcessingResultResponse = { + queued: number; + skipped: number; +}; + export type Category = { readonly medals: UserMedalResponse[]; }; @@ -332,6 +377,11 @@ export enum CountryCode { MF = "MF", } +export type CreateScoreProcessingTaskRequest = { + score_id: number; + action: ScoreTaskType; +}; + export type CustomBeatmapStatusChangeResponse = { beatmap: BeatmapResponse; new_status: BeatmapStatusWeb; @@ -418,6 +468,21 @@ export type EditUserRestrictionRequest = { restriction_reason?: string | null; }; +export type EventScoreProcessingListResponse = { + events: EventScoreProcessingResponse[]; + total_count: number; +}; + +export type EventScoreProcessingResponse = { + id: number; + event_type: ScoreProcessingEventType; + executor?: UserResponse; + score_id?: number | null; + task_id?: number | null; + json_data?: string | null; + created_at: string; +}; + export type EventUserResponse = { id: number; user: UserResponse; @@ -673,6 +738,71 @@ export type ResetPasswordRequest = { new_password: string; }; +export enum ScoreProcessingErrorCode { + UNEXPECTED = "Unexpected", + BEATMAP_NOT_FOUND = "BeatmapNotFound", + DUPLICATE_SCORE = "DuplicateScore", + PP_CALCULATION_FAILED = "PpCalculationFailed", + REPLAY_MISSING = "ReplayMissing", + INVALID_MODS = "InvalidMods", + BANNABLE_PP_THRESHOLD = "BannablePpThreshold", + INVALID_CHECKSUMS = "InvalidChecksums", + USER_NOT_FOUND = "UserNotFound", + USER_STATS_NOT_FOUND = "UserStatsNotFound", + USER_GRADES_NOT_FOUND = "UserGradesNotFound", + TRANSACTION_FAILED = "TransactionFailed", + PARSED_SCORE_INVALID = "ParsedScoreInvalid", + CANCELLED_BY_OPERATOR = "CancelledByOperator", + INVALID_SCORE_STATE = "InvalidScoreState", +} + +export enum ScoreProcessingEventType { + RECALCULATION_REQUESTED = "RecalculationRequested", + RESTORE_REQUESTED = "RestoreRequested", + DELETE_REQUESTED = "DeleteRequested", + SUBMISSION_ENQUEUED = "SubmissionEnqueued", + CANCELLED = "Cancelled", + REQUEUED = "Requeued", + BULK_REQUESTED = "BulkRequested", +} + +export type ScoreProcessingPreviewResponse = { + score: AdminScoreResponse; + active_task?: ScoreProcessingTaskResponse; +}; + +export type ScoreProcessingStatsResponse = { + pending: number; + processing: number; + failed: number; + estimated_pending_completion_seconds?: number | null; +}; + +export enum ScoreProcessingStatus { + PENDING = "Pending", + PROCESSING = "Processing", + FAILED = "Failed", +} + +export type ScoreProcessingTaskResponse = { + id: number; + task_type: ScoreTaskType; + status: ScoreProcessingStatus; + priority: number; + retry_count: number; + error_code?: ScoreProcessingErrorCode; + error_message?: string | null; + next_retry_at?: string | null; + created_at: string; + score_id?: number | null; + score?: AdminScoreResponse; +}; + +export type ScoreProcessingTasksResponse = { + tasks: ScoreProcessingTaskResponse[]; + total_count: number; +}; + export type ScoreResponse = { accuracy: number; beatmap_id: number; @@ -700,6 +830,11 @@ export type ScoreResponse = { user: UserResponse; }; +export enum ScoreSortType { + DATE = "Date", + PERFORMANCE = "Performance", +} + export type ScoreState = { maxCombo?: number | null; osuLargeTickHits?: number | null; @@ -719,6 +854,13 @@ export enum ScoreTableType { TOP = "Top", } +export enum ScoreTaskType { + SUBMISSION = "Submission", + RECALCULATION = "Recalculation", + RESTORE = "Restore", + DELETE = "Delete", +} + export type ScoresResponse = { scores: ScoreResponse[]; total_count: number; @@ -752,6 +894,14 @@ export type StatusResponse = { total_restrictions?: number | null; }; +export enum SubmissionStatus { + FAILED = "Failed", + SUBMITTED = "Submitted", + BEST = "Best", + DELETED = "Deleted", + UNKNOWN = "Unknown", +} + export type TokenRequest = { username: string; password: string; @@ -1742,6 +1892,386 @@ export type GetScoreTopResponses = { export type GetScoreTopResponse = GetScoreTopResponses[keyof GetScoreTopResponses]; +export type GetScoreProcessingData = { + body?: never; + path?: never; + query?: { + page?: number; + limit?: number; + status?: ScoreProcessingStatus; + task_type?: ScoreTaskType; + score_id?: number; + task_id?: number; + }; + url: "/score-processing"; +}; + +export type GetScoreProcessingErrors = { + /** + * Bad Request + */ + 400: ProblemDetailsResponseType; + /** + * Unauthorized + */ + 401: ProblemDetailsResponseType; + /** + * Forbidden + */ + 403: ProblemDetailsResponseType; +}; + +export type GetScoreProcessingError = GetScoreProcessingErrors[keyof GetScoreProcessingErrors]; + +export type GetScoreProcessingResponses = { + /** + * OK + */ + 200: ScoreProcessingTasksResponse; +}; + +export type GetScoreProcessingResponse = GetScoreProcessingResponses[keyof GetScoreProcessingResponses]; + +export type PostScoreProcessingData = { + body?: CreateScoreProcessingTaskRequest; + path?: never; + query?: never; + url: "/score-processing"; +}; + +export type PostScoreProcessingErrors = { + /** + * Bad Request + */ + 400: ProblemDetailsResponseType; + /** + * Unauthorized + */ + 401: ProblemDetailsResponseType; + /** + * Forbidden + */ + 403: ProblemDetailsResponseType; + /** + * Not Found + */ + 404: ProblemDetailsResponseType; + /** + * Conflict + */ + 409: ProblemDetailsResponseType; +}; + +export type PostScoreProcessingError = PostScoreProcessingErrors[keyof PostScoreProcessingErrors]; + +export type PostScoreProcessingResponses = { + /** + * OK + */ + 200: unknown; + /** + * Created + */ + 201: ScoreProcessingTaskResponse; +}; + +export type PostScoreProcessingResponse = PostScoreProcessingResponses[keyof PostScoreProcessingResponses]; + +export type GetScoreProcessingStatsData = { + body?: never; + path?: never; + query?: never; + url: "/score-processing/stats"; +}; + +export type GetScoreProcessingStatsErrors = { + /** + * Bad Request + */ + 400: ProblemDetailsResponseType; + /** + * Unauthorized + */ + 401: ProblemDetailsResponseType; + /** + * Forbidden + */ + 403: ProblemDetailsResponseType; +}; + +export type GetScoreProcessingStatsError = GetScoreProcessingStatsErrors[keyof GetScoreProcessingStatsErrors]; + +export type GetScoreProcessingStatsResponses = { + /** + * OK + */ + 200: ScoreProcessingStatsResponse; +}; + +export type GetScoreProcessingStatsResponse = GetScoreProcessingStatsResponses[keyof GetScoreProcessingStatsResponses]; + +export type GetScoreProcessingByIdData = { + body?: never; + path: { + id: number; + }; + query?: never; + url: "/score-processing/{id}"; +}; + +export type GetScoreProcessingByIdErrors = { + /** + * Bad Request + */ + 400: ProblemDetailsResponseType; + /** + * Unauthorized + */ + 401: ProblemDetailsResponseType; + /** + * Forbidden + */ + 403: ProblemDetailsResponseType; + /** + * Not Found + */ + 404: ProblemDetailsResponseType; +}; + +export type GetScoreProcessingByIdError = GetScoreProcessingByIdErrors[keyof GetScoreProcessingByIdErrors]; + +export type GetScoreProcessingByIdResponses = { + /** + * OK + */ + 200: ScoreProcessingTaskResponse; +}; + +export type GetScoreProcessingByIdResponse = GetScoreProcessingByIdResponses[keyof GetScoreProcessingByIdResponses]; + +export type GetScoreProcessingScoreByScoreIdData = { + body?: never; + path: { + scoreId: number; + }; + query?: never; + url: "/score-processing/score/{scoreId}"; +}; + +export type GetScoreProcessingScoreByScoreIdErrors = { + /** + * Bad Request + */ + 400: ProblemDetailsResponseType; + /** + * Unauthorized + */ + 401: ProblemDetailsResponseType; + /** + * Forbidden + */ + 403: ProblemDetailsResponseType; + /** + * Not Found + */ + 404: ProblemDetailsResponseType; +}; + +export type GetScoreProcessingScoreByScoreIdError = GetScoreProcessingScoreByScoreIdErrors[keyof GetScoreProcessingScoreByScoreIdErrors]; + +export type GetScoreProcessingScoreByScoreIdResponses = { + /** + * OK + */ + 200: ScoreProcessingPreviewResponse; +}; + +export type GetScoreProcessingScoreByScoreIdResponse = GetScoreProcessingScoreByScoreIdResponses[keyof GetScoreProcessingScoreByScoreIdResponses]; + +export type PostScoreProcessingByIdCancelData = { + body?: never; + path: { + id: number; + }; + query?: never; + url: "/score-processing/{id}/cancel"; +}; + +export type PostScoreProcessingByIdCancelErrors = { + /** + * Bad Request + */ + 400: ProblemDetailsResponseType; + /** + * Unauthorized + */ + 401: ProblemDetailsResponseType; + /** + * Forbidden + */ + 403: ProblemDetailsResponseType; + /** + * Not Found + */ + 404: ProblemDetailsResponseType; + /** + * Conflict + */ + 409: ProblemDetailsResponseType; +}; + +export type PostScoreProcessingByIdCancelError = PostScoreProcessingByIdCancelErrors[keyof PostScoreProcessingByIdCancelErrors]; + +export type PostScoreProcessingByIdCancelResponses = { + /** + * OK + */ + 200: unknown; +}; + +export type PostScoreProcessingByIdRequeueData = { + body?: never; + path: { + id: number; + }; + query?: never; + url: "/score-processing/{id}/requeue"; +}; + +export type PostScoreProcessingByIdRequeueErrors = { + /** + * Bad Request + */ + 400: ProblemDetailsResponseType; + /** + * Unauthorized + */ + 401: ProblemDetailsResponseType; + /** + * Forbidden + */ + 403: ProblemDetailsResponseType; + /** + * Not Found + */ + 404: ProblemDetailsResponseType; + /** + * Conflict + */ + 409: ProblemDetailsResponseType; +}; + +export type PostScoreProcessingByIdRequeueError = PostScoreProcessingByIdRequeueErrors[keyof PostScoreProcessingByIdRequeueErrors]; + +export type PostScoreProcessingByIdRequeueResponses = { + /** + * OK + */ + 200: unknown; +}; + +export type PostScoreProcessingBulkData = { + body?: BulkScoreProcessingRequest; + path?: never; + query?: never; + url: "/score-processing/bulk"; +}; + +export type PostScoreProcessingBulkErrors = { + /** + * Bad Request + */ + 400: ProblemDetailsResponseType; + /** + * Unauthorized + */ + 401: ProblemDetailsResponseType; + /** + * Forbidden + */ + 403: ProblemDetailsResponseType; +}; + +export type PostScoreProcessingBulkError = PostScoreProcessingBulkErrors[keyof PostScoreProcessingBulkErrors]; + +export type PostScoreProcessingBulkResponses = { + /** + * OK + */ + 200: BulkScoreProcessingResultResponse; +}; + +export type PostScoreProcessingBulkResponse = PostScoreProcessingBulkResponses[keyof PostScoreProcessingBulkResponses]; + +export type PostScoreProcessingBulkByFilterData = { + body?: BulkScoreProcessingByFilterRequest; + path?: never; + query?: never; + url: "/score-processing/bulk-by-filter"; +}; + +export type PostScoreProcessingBulkByFilterErrors = { + /** + * Bad Request + */ + 400: ProblemDetailsResponseType; + /** + * Unauthorized + */ + 401: ProblemDetailsResponseType; + /** + * Forbidden + */ + 403: ProblemDetailsResponseType; +}; + +export type PostScoreProcessingBulkByFilterError = PostScoreProcessingBulkByFilterErrors[keyof PostScoreProcessingBulkByFilterErrors]; + +export type PostScoreProcessingBulkByFilterResponses = { + /** + * OK + */ + 200: unknown; +}; + +export type GetScoreProcessingEventsData = { + body?: never; + path?: never; + query?: { + page?: number; + limit?: number; + types?: ScoreProcessingEventType[]; + score_id?: number; + }; + url: "/score-processing/events"; +}; + +export type GetScoreProcessingEventsErrors = { + /** + * Bad Request + */ + 400: ProblemDetailsResponseType; + /** + * Unauthorized + */ + 401: ProblemDetailsResponseType; + /** + * Forbidden + */ + 403: ProblemDetailsResponseType; +}; + +export type GetScoreProcessingEventsError = GetScoreProcessingEventsErrors[keyof GetScoreProcessingEventsErrors]; + +export type GetScoreProcessingEventsResponses = { + /** + * OK + */ + 200: EventScoreProcessingListResponse; +}; + +export type GetScoreProcessingEventsResponse = GetScoreProcessingEventsResponses[keyof GetScoreProcessingEventsResponses]; + export type GetUserByIdData = { body?: never; path: { @@ -2168,6 +2698,47 @@ export type GetUserByIdScoresResponses = { export type GetUserByIdScoresResponse = GetUserByIdScoresResponses[keyof GetUserByIdScoresResponses]; +export type GetUserByIdScoresAdminData = { + body?: never; + path: { + id: number; + }; + query?: { + mode?: GameMode; + mods?: Mods[]; + submission_status?: SubmissionStatus; + beatmap_status?: BeatmapStatus; + submitted_from?: string; + submitted_to?: string; + sort?: ScoreSortType; + limit?: number; + page?: number; + }; + url: "/user/{id}/scores/admin"; +}; + +export type GetUserByIdScoresAdminErrors = { + /** + * Bad Request + */ + 400: ProblemDetailsResponseType; + /** + * Not Found + */ + 404: ProblemDetailsResponseType; +}; + +export type GetUserByIdScoresAdminError = GetUserByIdScoresAdminErrors[keyof GetUserByIdScoresAdminErrors]; + +export type GetUserByIdScoresAdminResponses = { + /** + * OK + */ + 200: AdminScoresResponse; +}; + +export type GetUserByIdScoresAdminResponse = GetUserByIdScoresAdminResponses[keyof GetUserByIdScoresAdminResponses]; + export type GetUserByIdMostplayedData = { body?: never; path: { diff --git a/lib/types/api/zod.gen.ts b/lib/types/api/zod.gen.ts index 44465e92..0a11ea82 100644 --- a/lib/types/api/zod.gen.ts +++ b/lib/types/api/zod.gen.ts @@ -2,6 +2,21 @@ import { z } from "zod"; +export const zGameMode = z.enum([ + "Standard", + "Taiko", + "CatchTheBeat", + "Mania", + "RelaxStandard", + "RelaxTaiko", + "RelaxCatchTheBeat", + "AutopilotStandard", + "ScoreV2Standard", + "ScoreV2Taiko", + "ScoreV2CatchTheBeat", + "ScoreV2Mania", +]); + export const zCountryCode = z.enum([ "XX", "AD", @@ -222,21 +237,6 @@ export const zCountryCode = z.enum([ "MF", ]); -export const zGameMode = z.enum([ - "Standard", - "Taiko", - "CatchTheBeat", - "Mania", - "RelaxStandard", - "RelaxTaiko", - "RelaxCatchTheBeat", - "AutopilotStandard", - "ScoreV2Standard", - "ScoreV2Taiko", - "ScoreV2CatchTheBeat", - "ScoreV2Mania", -]); - export const zUserBadge = z.enum([ "Developer", "Admin", @@ -267,6 +267,77 @@ export const zUserResponse = z.object({ user_status: z.string(), }); +export const zScoreResponse = z.object({ + accuracy: z.number(), + beatmap_id: z.number().int(), + count_100: z.number().int(), + count_300: z.number().int(), + count_50: z.number().int(), + count_geki: z.number().int(), + count_katu: z.number().int(), + count_miss: z.number().int(), + game_mode: zGameMode, + game_mode_extended: zGameMode, + grade: z.string(), + id: z.number().int(), + is_passed: z.boolean(), + has_replay: z.boolean(), + leaderboard_rank: z.union([ + z.number().int(), + z.null(), + ]).optional(), + max_combo: z.number().int(), + mods: z.union([ + z.string(), + z.null(), + ]).optional(), + mods_int: z.union([ + z.number().int(), + z.null(), + ]).optional(), + is_perfect: z.boolean(), + performance_points: z.number(), + total_score: z.coerce.bigint(), + user_id: z.number().int(), + when_played: z.string().datetime(), + user: zUserResponse, +}); + +export const zSubmissionStatus = z.enum([ + "Failed", + "Submitted", + "Best", + "Deleted", + "Unknown", +]); + +export const zBeatmapStatus = z.enum([ + "Pending", + "NeedsUpdate", + "Ranked", + "Approved", + "Qualified", + "Loved", + "Unknown", + "NotSubmitted", +]); + +export const zAdminScoreResponse = z.object({ + score: zScoreResponse, + submission_status: zSubmissionStatus, + beatmap_status: zBeatmapStatus, + is_scoreable: z.boolean(), + score_hash: z.union([ + z.string(), + z.null(), + ]).optional(), +}); + +export const zAdminScoresResponse = z.object({ + scores: z.array(zAdminScoreResponse), + total_count: z.number().int(), +}); + export const zBeatmapEventType = z.enum([ "BeatmapSetHyped", "BeatmapStatusChanged", @@ -396,6 +467,78 @@ export const zBeatmapSetsResponse = z.object({ ]).optional(), }); +export const zScoreTaskType = z.enum([ + "Submission", + "Recalculation", + "Restore", + "Delete", +]); + +export const zMods = z.enum([ + "None", + "NoFail", + "Easy", + "TouchDevice", + "Hidden", + "HardRock", + "SuddenDeath", + "DoubleTime", + "Relax", + "HalfTime", + "Nightcore", + "Flashlight", + "Autoplay", + "SpunOut", + "Relax2", + "Perfect", + "Key4", + "Key5", + "Key6", + "Key7", + "Key8", + "FadeIn", + "Random", + "Cinema", + "Target", + "Key9", + "KeyCoop", + "Key1", + "Key3", + "Key2", + "ScoreV2", + "Mirror", +]); + +export const zBulkScoreProcessingByFilterRequest = z.object({ + action: zScoreTaskType, + user_id: z.number().int().gte(1).lte(2147483647), + mode: zGameMode.optional(), + mods: z.union([ + z.array(zMods), + z.null(), + ]).optional(), + submission_status: zSubmissionStatus.optional(), + beatmap_status: zBeatmapStatus.optional(), + submitted_from: z.union([ + z.string().datetime(), + z.null(), + ]).optional(), + submitted_to: z.union([ + z.string().datetime(), + z.null(), + ]).optional(), +}); + +export const zBulkScoreProcessingRequest = z.object({ + score_ids: z.array(z.number().int()).min(1), + action: zScoreTaskType, +}); + +export const zBulkScoreProcessingResultResponse = z.object({ + queued: z.number().int(), + skipped: z.number().int(), +}); + export const zUserMedalResponse = z.object({ id: z.number().int().readonly(), name: z.string().readonly(), @@ -419,6 +562,11 @@ export const zCountryChangeRequest = z.object({ new_country: zCountryCode, }); +export const zCreateScoreProcessingTaskRequest = z.object({ + score_id: z.number().int().gte(1).lte(2147483647), + action: zScoreTaskType, +}); + export const zCustomBeatmapStatusChangeResponse = z.object({ beatmap: zBeatmapResponse, new_status: zBeatmapStatusWeb, @@ -636,6 +784,40 @@ export const zEditUserRestrictionRequest = z.object({ ]).optional(), }); +export const zScoreProcessingEventType = z.enum([ + "RecalculationRequested", + "RestoreRequested", + "DeleteRequested", + "SubmissionEnqueued", + "Cancelled", + "Requeued", + "BulkRequested", +]); + +export const zEventScoreProcessingResponse = z.object({ + id: z.number().int(), + event_type: zScoreProcessingEventType, + executor: zUserResponse.optional(), + score_id: z.union([ + z.number().int(), + z.null(), + ]).optional(), + task_id: z.union([ + z.number().int(), + z.null(), + ]).optional(), + json_data: z.union([ + z.string(), + z.null(), + ]).optional(), + created_at: z.string().datetime(), +}); + +export const zEventScoreProcessingListResponse = z.object({ + events: z.array(zEventScoreProcessingResponse), + total_count: z.number().int(), +}); + export const zUserEventType = z.enum([ "GameLogin", "WebLogin", @@ -792,41 +974,6 @@ export const zMedalsResponse = z.object({ skill: zCategory, }); -export const zMods = z.enum([ - "None", - "NoFail", - "Easy", - "TouchDevice", - "Hidden", - "HardRock", - "SuddenDeath", - "DoubleTime", - "Relax", - "HalfTime", - "Nightcore", - "Flashlight", - "Autoplay", - "SpunOut", - "Relax2", - "Perfect", - "Key4", - "Key5", - "Key6", - "Key7", - "Key8", - "FadeIn", - "Random", - "Cinema", - "Target", - "Key9", - "KeyCoop", - "Key1", - "Key3", - "Key2", - "ScoreV2", - "Mirror", -]); - export const zMostPlayedBeatmapResponse = z.object({ id: z.number().int(), beatmapset_id: z.number().int(), @@ -1028,42 +1175,78 @@ export const zResetPasswordRequest = z.object({ new_password: z.string().min(1), }); -export const zScoreResponse = z.object({ - accuracy: z.number(), - beatmap_id: z.number().int(), - count_100: z.number().int(), - count_300: z.number().int(), - count_50: z.number().int(), - count_geki: z.number().int(), - count_katu: z.number().int(), - count_miss: z.number().int(), - game_mode: zGameMode, - game_mode_extended: zGameMode, - grade: z.string(), +export const zScoreProcessingErrorCode = z.enum([ + "Unexpected", + "BeatmapNotFound", + "DuplicateScore", + "PpCalculationFailed", + "ReplayMissing", + "InvalidMods", + "BannablePpThreshold", + "InvalidChecksums", + "UserNotFound", + "UserStatsNotFound", + "UserGradesNotFound", + "TransactionFailed", + "ParsedScoreInvalid", + "CancelledByOperator", + "InvalidScoreState", +]); + +export const zScoreProcessingStatus = z.enum([ + "Pending", + "Processing", + "Failed", +]); + +export const zScoreProcessingTaskResponse = z.object({ id: z.number().int(), - is_passed: z.boolean(), - has_replay: z.boolean(), - leaderboard_rank: z.union([ - z.number().int(), + task_type: zScoreTaskType, + status: zScoreProcessingStatus, + priority: z.number().int(), + retry_count: z.number().int(), + error_code: zScoreProcessingErrorCode.optional(), + error_message: z.union([ + z.string(), z.null(), ]).optional(), - max_combo: z.number().int(), - mods: z.union([ - z.string(), + next_retry_at: z.union([ + z.string().datetime(), z.null(), ]).optional(), - mods_int: z.union([ + created_at: z.string().datetime(), + score_id: z.union([ z.number().int(), z.null(), ]).optional(), - is_perfect: z.boolean(), - performance_points: z.number(), - total_score: z.coerce.bigint(), - user_id: z.number().int(), - when_played: z.string().datetime(), - user: zUserResponse, + score: zAdminScoreResponse.optional(), +}); + +export const zScoreProcessingPreviewResponse = z.object({ + score: zAdminScoreResponse, + active_task: zScoreProcessingTaskResponse.optional(), }); +export const zScoreProcessingStatsResponse = z.object({ + pending: z.coerce.bigint(), + processing: z.coerce.bigint(), + failed: z.coerce.bigint(), + estimated_pending_completion_seconds: z.union([ + z.number(), + z.null(), + ]).optional(), +}); + +export const zScoreProcessingTasksResponse = z.object({ + tasks: z.array(zScoreProcessingTaskResponse), + total_count: z.number().int(), +}); + +export const zScoreSortType = z.enum([ + "Date", + "Performance", +]); + export const zScoreTableType = z.enum([ "Best", "Recent", @@ -1245,6 +1428,23 @@ export const zGetScoreByIdReplayResponse = z.string(); export const zGetScoreTopResponse = zScoresResponse; +export const zGetScoreProcessingResponse = zScoreProcessingTasksResponse; + +export const zPostScoreProcessingResponse = z.union([ + z.unknown(), + zScoreProcessingTaskResponse, +]); + +export const zGetScoreProcessingStatsResponse = zScoreProcessingStatsResponse; + +export const zGetScoreProcessingByIdResponse = zScoreProcessingTaskResponse; + +export const zGetScoreProcessingScoreByScoreIdResponse = zScoreProcessingPreviewResponse; + +export const zPostScoreProcessingBulkResponse = zBulkScoreProcessingResultResponse; + +export const zGetScoreProcessingEventsResponse = zEventScoreProcessingListResponse; + export const zGetUserByIdResponse = zUserResponse; export const zGetUserByIdSensitiveResponse = zUserSensitiveResponse; @@ -1261,6 +1461,8 @@ export const zGetUserByUserIdPlayHistoryGraphResponse = zPlayHistorySnapshotsRes export const zGetUserByIdScoresResponse = zScoresResponse; +export const zGetUserByIdScoresAdminResponse = zAdminScoresResponse; + export const zGetUserByIdMostplayedResponse = zMostPlayedResponse; export const zGetUserByIdFavouritesResponse = zBeatmapSetsResponse; diff --git a/lib/utils/getStatusBadgeColor.ts b/lib/utils/getStatusBadgeColor.ts new file mode 100644 index 00000000..88f52991 --- /dev/null +++ b/lib/utils/getStatusBadgeColor.ts @@ -0,0 +1,14 @@ +import { ScoreProcessingStatus } from "@/lib/types/api"; + +export function getStatusBadgeColor(status: ScoreProcessingStatus): string { + switch (status) { + case ScoreProcessingStatus.PENDING: + return "yellow-500"; + case ScoreProcessingStatus.PROCESSING: + return "blue-500"; + case ScoreProcessingStatus.FAILED: + return "red-500"; + default: + return "muted"; + } +}