import { useState, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; import { Upload, Loader2, FileText, X, ChevronDown, ChevronUp, Plus, CheckCircle, CircleSlash, ClipboardCheck, Trash2, } from "lucide-react"; import { SearchInput } from "@/components/SearchInput"; import { Card, CardContent, } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Badge } from "@/components/ui/badge"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { cn } from "@/lib/utils"; import { MessageLine } from "@/components/MessageLine"; import { InlineConfirm } from "@/components/InlineConfirm"; import { Loading, ErrorState, EmptyState } from "@/components/States"; import { Pagination } from "@/components/Pagination"; import { StatCard } from "@/components/StatCard"; import { PageHeader } from "@/components/PageHeader"; import { SubmissionStatusBadge } from "@/components/SubmissionStatusBadge"; import { api, humanizeError } from "@/lib/api"; import { useAsync } from "@/lib/hooks"; import { formatRelative, formatAbsolute } from "@/lib/format"; import type { BuildStatus, Submission, SubmissionStatus } from "@/lib/types"; const PAGE_SIZE = 10; // The linked build's outcome as shown in a row's expanded details. Colors mirror // the admin build page; the labels are player-facing, so they come from this // page's namespace instead of the raw status string. const BUILD_STATUS_STYLE: Record = { pending: "bg-amber-500/10 text-amber-500 border-amber-500/20 animate-pulse", building: "bg-blue-500/10 text-blue-500 border-blue-500/20 animate-pulse", succeeded: "bg-emerald-500/10 text-emerald-500 border-emerald-500/20", failed: "bg-rose-500/10 text-rose-500 border-rose-500/20", cancelled: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20", }; const BUILD_STATUS_I18N: Record = { pending: "build_status_pending", building: "build_status_building", succeeded: "build_status_succeeded", failed: "build_status_failed", cancelled: "build_status_cancelled", }; function formatBytes(bytes: number, decimals = 2) { if (bytes === 0) return "0 Bytes"; const k = 1024; const dm = decimals < 0 ? 0 : decimals; const sizes = ["Bytes", "KB", "MB", "GB", "TB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i]; } export function MySubmissionsPage() { const { t, i18n } = useTranslation("submissions"); const locale = i18n.language; const now = Date.now(); // Async API hook const { data, error: fetchError, loading, reload } = useAsync(() => api.listMySubmissions(), []); const submissions = useMemo(() => data ?? [], [data]); // Dialog State const [dialogOpen, setDialogOpen] = useState(false); // Form State const [displayName, setDisplayName] = useState(""); const [file, setFile] = useState(null); const [dragActive, setDragActive] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [submitStep, setSubmitStep] = useState<"create" | "upload" | null>(null); const [error, setError] = useState(null); // Row action state: withdraw arms a row (trash → confirm/cancel) before it // fires, and a failure lands in actionError above the list. const [confirmingWithdraw, setConfirmingWithdraw] = useState(null); const [withdrawing, setWithdrawing] = useState(null); const [actionError, setActionError] = useState(null); const fileInputRef = useRef(null); // Search & Filtering State const [search, setSearch] = useState(""); const [statusFilter, setStatusFilter] = useState<"all" | SubmissionStatus>("all"); const [page, setPage] = useState(1); const [expandedId, setExpandedId] = useState(null); // Filtered & Paginated Submissions const filteredSubmissions = useMemo(() => { let list = [...submissions]; if (search.trim()) { const q = search.toLowerCase(); list = list.filter( (s) => s.display_name.toLowerCase().includes(q) || s.id.toLowerCase().includes(q), ); } if (statusFilter !== "all") { list = list.filter((s) => s.status === statusFilter); } return list; }, [submissions, search, statusFilter]); // Reset page when filter changes const lastFilterKey = `${search}-${statusFilter}`; const [prevFilterKey, setPrevFilterKey] = useState(lastFilterKey); if (prevFilterKey !== lastFilterKey) { setPage(1); setPrevFilterKey(lastFilterKey); } const paginatedSubmissions = useMemo(() => { const start = (page - 1) * PAGE_SIZE; return filteredSubmissions.slice(start, start + PAGE_SIZE); }, [filteredSubmissions, page]); // Stats const stats = useMemo(() => { const total = submissions.length; const pending = submissions.filter((s) => s.status === "pending_review").length; const approved = submissions.filter((s) => s.status === "approved").length; const rejected = submissions.filter((s) => s.status === "rejected").length; return { total, pending, approved, rejected }; }, [submissions]); // Drag and drop event handlers const handleDrag = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); if (e.type === "dragenter" || e.type === "dragover") { setDragActive(true); } else if (e.type === "dragleave") { setDragActive(false); } }; const handleDrop = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setDragActive(false); if (e.dataTransfer.files && e.dataTransfer.files[0]) { validateAndSetFile(e.dataTransfer.files[0]); } }; const handleFileChange = (e: React.ChangeEvent) => { const selectedFile = e.target.files?.[0] || null; validateAndSetFile(selectedFile); }; const validateAndSetFile = (selectedFile: File | null) => { setError(null); if (!selectedFile) { setFile(null); return; } if (!selectedFile.name.endsWith(".tar.gz")) { setError(t("error_file_type")); setFile(null); return; } const maxBytes = 1024 * 1024 * 1024; // 1 GiB limit if (selectedFile.size > maxBytes) { setError(t("error_file_size")); setFile(null); return; } setFile(selectedFile); }; const handleFormSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); const trimmedName = displayName.trim(); if (!trimmedName) { setError(t("error_name_required")); return; } if (!file) { setError(t("error_file_required")); return; } setIsSubmitting(true); setSubmitStep("create"); try { // 1. Create the submission metadata const sub = await api.createSubmission(trimmedName); // 2. Upload context file setSubmitStep("upload"); await api.uploadSubmissionContext(sub.id, file); setDisplayName(""); setFile(null); if (fileInputRef.current) { fileInputRef.current.value = ""; } setDialogOpen(false); reload(); } catch (err) { setError(humanizeError(err)); } finally { setIsSubmitting(false); setSubmitStep(null); } }; // Withdraw retracts a still-pending submission and its uploaded context, // freeing the pending slot and the storage budget for a fresh submission. async function handleWithdraw(id: string) { if (withdrawing) return; setWithdrawing(id); setActionError(null); try { await api.withdrawSubmission(id); setConfirmingWithdraw(null); reload(); } catch (err) { setActionError(humanizeError(err)); } finally { setWithdrawing(null); } } return (
{/* Header */} { setError(null); setDisplayName(""); setFile(null); setDialogOpen(true); }} size="sm" className="gap-1.5" > {t("submit_card_title")} } className="mb-6" /> {/* Row-action error (withdraw) */} {actionError && } {/* Stats Cards Row */}
0 && "text-amber-500 bg-amber-500/10")} />
{/* Submission History Card */} {/* Filters Bar */}
{/* List Content */} {loading && !data ? (
) : fetchError ? (
) : filteredSubmissions.length === 0 ? (
) : (
{/* Table Header */}
{t("table_display_name")}
{t("table_status")}
{t("table_created_at")}
{/* Table Rows */}
{paginatedSubmissions.map((sub) => { const isExpanded = expandedId === sub.id; return (
{/* Row Header */}
setExpandedId(isExpanded ? null : sub.id)} className={cn( "grid grid-cols-1 md:grid-cols-12 gap-3 px-4 py-3.5 items-center cursor-pointer transition-colors text-sm hover:bg-muted/30", isExpanded && "bg-muted/20", )} > {/* Display Name */}
{isExpanded ? ( ) : ( )}
{sub.display_name} ID: {sub.id}
{/* Status */}
{/* Created At */}
{formatRelative(sub.created_at, now, locale)}
{/* Expandable details block */} {isExpanded && (

{t("field_context_ref")}

{sub.context_ref}
{sub.context_sha256 && (

{t("field_context_sha256")}

{sub.context_sha256}
)} {sub.image_ref && (

{t("field_image_ref")}

{sub.image_ref}
)}
{sub.status === "pending_review" && (
{t("withdraw_hint")} {confirmingWithdraw === sub.id ? ( handleWithdraw(sub.id)} onCancel={() => setConfirmingWithdraw(null)} confirmLabel={t("withdraw_confirm")} cancelLabel={t("withdraw_cancel")} className="flex shrink-0 items-center gap-1" /> ) : ( )}
)} {sub.status !== "pending_review" && (
{sub.reviewed_by && (
{t("table_reviewed_by")}: {sub.reviewed_by} {sub.reviewed_at && ( ({formatAbsolute(sub.reviewed_at, locale)}) )}
)} {sub.status === "rejected" && sub.reject_reason && (
{t("table_reject_reason")}: {sub.reject_reason}
)}
)} {/* Linked build outcome — a submitter's only view of a failed build (the build pages are admin-tier). */} {sub.build_id && sub.build_status && (
{t("table_build_status")}: {t(BUILD_STATUS_I18N[sub.build_status])} {sub.build_id}
{sub.build_status === "failed" && sub.build_error && (
{t("table_build_error")}: {sub.build_error}
)}
)}
)}
); })}
{/* Pagination Footer */} {filteredSubmissions.length > PAGE_SIZE && (
)}
)}
{/* Submit Modpack Dialog */} !isSubmitting && setDialogOpen(open)}> {t("submit_card_title")}

{t("submit_card_desc")}

{/* Error Banner */} {error && } {/* Display Name Input */}
setDisplayName(e.target.value)} disabled={isSubmitting} required />
{/* File Dropzone */}
!isSubmitting && fileInputRef.current?.click()} className={cn( "flex flex-col items-center justify-center border border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors min-h-[140px]", dragActive ? "border-primary bg-primary/5" : "border-border hover:bg-muted/30", isSubmitting && "opacity-50 cursor-not-allowed", )} > {file ? (

{file.name}

{formatBytes(file.size)}

{!isSubmitting && ( )}
) : ( <>

{t("file_drag_hint")}

{t("file_hint")}

)}
); }