- Backend: add terrain field to Arma3MissionManager.list_missions() - Backend: add missions field to ServerConfig Pydantic model - Backend: add GET /missions/rotation and PUT /missions/rotation endpoints - Frontend: Mission type gains terrain field; new MissionRotationEntry type - Frontend: useServerMissionRotation and useUpdateMissionRotation hooks - Frontend: useUploadMission updated to accept File[] with sequential upload - Frontend: MissionList redesigned with Available Missions + Mission Rotation sections - Frontend: per-file upload progress tracking, terrain badges, difficulty select - Tests: 5 new tests; fixed existing useUploadMission test for File[] API; 141 pass
333 lines
13 KiB
TypeScript
333 lines
13 KiB
TypeScript
import { useState, useRef, useEffect } from "react";
|
|
import { Upload, Trash2, Plus, X, Save } from "lucide-react";
|
|
|
|
import {
|
|
useServerMissions,
|
|
useServerMissionRotation,
|
|
useUpdateMissionRotation,
|
|
useUploadMission,
|
|
useDeleteMission,
|
|
useServerConfigSection,
|
|
} from "@/hooks/useServerDetail";
|
|
import type { MissionRotationEntry } from "@/hooks/useServerDetail";
|
|
import { useAuthStore } from "@/store/auth.store";
|
|
import { useUIStore } from "@/store/ui.store";
|
|
import { logger } from "@/lib/logger";
|
|
|
|
interface MissionListProps {
|
|
serverId: number;
|
|
}
|
|
|
|
const DIFFICULTY_OPTIONS = ["", "Recruit", "Regular", "Veteran", "Custom"];
|
|
|
|
interface UploadProgress {
|
|
filename: string;
|
|
done: boolean;
|
|
}
|
|
|
|
export function MissionList({ serverId }: MissionListProps) {
|
|
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
|
|
const addNotification = useUIStore((s) => s.addNotification);
|
|
|
|
const { data: missionsData, isLoading: missionsLoading } = useServerMissions(serverId);
|
|
const { data: rotationData, isLoading: rotationLoading } = useServerMissionRotation(serverId);
|
|
const { data: serverSection } = useServerConfigSection(serverId, "server");
|
|
const updateRotation = useUpdateMissionRotation(serverId);
|
|
const uploadMission = useUploadMission(serverId);
|
|
const deleteMission = useDeleteMission(serverId);
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const [rotation, setRotation] = useState<MissionRotationEntry[]>([]);
|
|
const [uploadProgress, setUploadProgress] = useState<UploadProgress[]>([]);
|
|
|
|
// Sync rotation from query on load
|
|
useEffect(() => {
|
|
if (rotationData) setRotation(rotationData);
|
|
}, [rotationData]);
|
|
|
|
const configVersion = (serverSection?._meta as { config_version?: number })?.config_version ?? 0;
|
|
|
|
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = Array.from(e.target.files ?? []);
|
|
if (files.length === 0) return;
|
|
|
|
const progress: UploadProgress[] = files.map((f) => ({ filename: f.name, done: false }));
|
|
setUploadProgress(progress);
|
|
|
|
try {
|
|
for (let i = 0; i < files.length; i++) {
|
|
await uploadMission.mutateAsync([files[i]]);
|
|
setUploadProgress((prev) => prev.map((p, idx) => (idx === i ? { ...p, done: true } : p)));
|
|
}
|
|
addNotification({ type: "success", message: `${files.length} mission(s) uploaded` });
|
|
} catch (err) {
|
|
logger.error("MissionList", "Failed to upload missions: %s", err);
|
|
addNotification({ type: "error", message: "Failed to upload one or more missions" });
|
|
}
|
|
|
|
setUploadProgress([]);
|
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
|
};
|
|
|
|
const handleDelete = async (filename: string) => {
|
|
try {
|
|
await deleteMission.mutateAsync(filename);
|
|
addNotification({ type: "info", message: `Mission ${filename} deleted` });
|
|
} catch (err) {
|
|
logger.error("MissionList", "Failed to delete mission %s: %s", filename, err);
|
|
addNotification({ type: "error", message: `Failed to delete ${filename}` });
|
|
}
|
|
};
|
|
|
|
const addToRotation = (missionName: string) => {
|
|
if (rotation.some((r) => r.name === missionName)) return;
|
|
setRotation([...rotation, { name: missionName, difficulty: "" }]);
|
|
};
|
|
|
|
const removeFromRotation = (idx: number) => {
|
|
setRotation(rotation.filter((_, i) => i !== idx));
|
|
};
|
|
|
|
const updateDifficulty = (idx: number, difficulty: string) => {
|
|
setRotation(rotation.map((r, i) => (i === idx ? { ...r, difficulty } : r)));
|
|
};
|
|
|
|
const handleSaveRotation = async () => {
|
|
try {
|
|
await updateRotation.mutateAsync({ missions: rotation, config_version: configVersion });
|
|
addNotification({ type: "success", message: "Mission rotation saved" });
|
|
} catch (err) {
|
|
logger.error("MissionList", "Failed to save rotation: %s", err);
|
|
addNotification({ type: "error", message: "Failed to save mission rotation" });
|
|
}
|
|
};
|
|
|
|
const handleClearRotation = () => setRotation([]);
|
|
|
|
if (missionsLoading || rotationLoading) {
|
|
return <div className="text-text-muted text-sm p-4">Loading missions...</div>;
|
|
}
|
|
|
|
const missions = missionsData?.missions ?? [];
|
|
|
|
return (
|
|
<div data-testid="mission-list" className="space-y-8">
|
|
{/* Section A: Available Missions */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h3 className="text-text-primary font-semibold">
|
|
Available Missions ({missions.length})
|
|
</h3>
|
|
{isAdmin && (
|
|
<label className="btn-primary flex items-center gap-1.5 text-sm cursor-pointer">
|
|
<Upload size={14} />
|
|
Upload .pbo
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept=".pbo"
|
|
multiple
|
|
onChange={handleUpload}
|
|
className="hidden"
|
|
disabled={uploadMission.isPending}
|
|
/>
|
|
</label>
|
|
)}
|
|
</div>
|
|
|
|
{uploadProgress.length > 0 && (
|
|
<div className="mb-3 space-y-1">
|
|
{uploadProgress.map((p) => (
|
|
<div key={p.filename} className="flex items-center gap-2 text-sm text-text-secondary">
|
|
{p.done ? (
|
|
<span className="text-green-400">✓</span>
|
|
) : (
|
|
<span className="animate-spin inline-block w-3 h-3 border border-accent border-t-transparent rounded-full" />
|
|
)}
|
|
{p.filename}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-surface-overlay">
|
|
<th className="text-left text-text-muted font-medium px-3 py-2">Mission Name</th>
|
|
<th className="text-left text-text-muted font-medium px-3 py-2">Terrain</th>
|
|
<th className="text-right text-text-muted font-medium px-3 py-2">Size</th>
|
|
{isAdmin && (
|
|
<th className="text-right text-text-muted font-medium px-3 py-2">Actions</th>
|
|
)}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{missions.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={isAdmin ? 4 : 3} className="text-text-muted text-center py-6">
|
|
No missions uploaded
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
missions.map((mission) => (
|
|
<tr
|
|
key={mission.filename}
|
|
className="border-b border-surface-overlay/50 hover:bg-surface-overlay/30"
|
|
>
|
|
<td className="text-text-primary px-3 py-2">{mission.name}</td>
|
|
<td className="px-3 py-2">
|
|
{mission.terrain ? (
|
|
<span className="bg-accent/20 text-accent text-xs px-2 py-0.5 rounded">
|
|
{mission.terrain}
|
|
</span>
|
|
) : (
|
|
<span className="text-text-muted text-xs">—</span>
|
|
)}
|
|
</td>
|
|
<td className="text-right font-mono text-text-muted text-xs px-3 py-2">
|
|
{formatSize(mission.size_bytes)}
|
|
</td>
|
|
{isAdmin && (
|
|
<td className="text-right px-3 py-2">
|
|
<div className="flex items-center justify-end gap-1">
|
|
<button
|
|
onClick={() => addToRotation(mission.name)}
|
|
disabled={rotation.some((r) => r.name === mission.name)}
|
|
className="btn-ghost text-xs flex items-center gap-1"
|
|
title="Add to rotation"
|
|
>
|
|
<Plus size={12} />
|
|
Rotation
|
|
</button>
|
|
<button
|
|
onClick={() => handleDelete(mission.filename)}
|
|
disabled={deleteMission.isPending}
|
|
className="btn-ghost text-status-crashed"
|
|
aria-label={`Delete ${mission.filename}`}
|
|
>
|
|
<Trash2 size={14} />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
)}
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Section B: Mission Rotation */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h3 className="text-text-primary font-semibold">
|
|
Mission Rotation ({rotation.length})
|
|
</h3>
|
|
{isAdmin && (
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={handleClearRotation}
|
|
className="btn-ghost text-sm text-status-crashed"
|
|
disabled={rotation.length === 0}
|
|
>
|
|
Clear
|
|
</button>
|
|
<button
|
|
onClick={handleSaveRotation}
|
|
disabled={updateRotation.isPending}
|
|
className="btn-primary flex items-center gap-1.5 text-sm"
|
|
>
|
|
<Save size={14} />
|
|
{updateRotation.isPending ? "Saving..." : "Save Rotation"}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-surface-overlay">
|
|
<th className="text-left text-text-muted font-medium px-3 py-2">#</th>
|
|
<th className="text-left text-text-muted font-medium px-3 py-2">Mission Name</th>
|
|
<th className="text-left text-text-muted font-medium px-3 py-2">Terrain</th>
|
|
<th className="text-left text-text-muted font-medium px-3 py-2">Difficulty</th>
|
|
{isAdmin && (
|
|
<th className="text-right text-text-muted font-medium px-3 py-2">Remove</th>
|
|
)}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rotation.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={isAdmin ? 5 : 4} className="text-text-muted text-center py-6">
|
|
No missions in rotation. Add from Available above.
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
rotation.map((entry, idx) => {
|
|
const missionFile = missions.find((m) => m.name === entry.name);
|
|
return (
|
|
<tr
|
|
key={`${entry.name}-${idx}`}
|
|
className="border-b border-surface-overlay/50 hover:bg-surface-overlay/30"
|
|
>
|
|
<td className="text-text-muted font-mono text-xs px-3 py-2">{idx + 1}</td>
|
|
<td className="text-text-primary px-3 py-2">{entry.name}</td>
|
|
<td className="px-3 py-2">
|
|
{missionFile?.terrain ? (
|
|
<span className="bg-accent/20 text-accent text-xs px-2 py-0.5 rounded">
|
|
{missionFile.terrain}
|
|
</span>
|
|
) : (
|
|
<span className="text-text-muted text-xs">—</span>
|
|
)}
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
{isAdmin ? (
|
|
<select
|
|
className="neu-input text-sm py-1"
|
|
value={entry.difficulty}
|
|
onChange={(e) => updateDifficulty(idx, e.target.value)}
|
|
>
|
|
{DIFFICULTY_OPTIONS.map((opt) => (
|
|
<option key={opt} value={opt}>
|
|
{opt || "Default"}
|
|
</option>
|
|
))}
|
|
</select>
|
|
) : (
|
|
<span className="text-text-secondary">{entry.difficulty || "Default"}</span>
|
|
)}
|
|
</td>
|
|
{isAdmin && (
|
|
<td className="text-right px-3 py-2">
|
|
<button
|
|
onClick={() => removeFromRotation(idx)}
|
|
className="btn-ghost text-status-crashed"
|
|
aria-label={`Remove ${entry.name} from rotation`}
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</td>
|
|
)}
|
|
</tr>
|
|
);
|
|
})
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function formatSize(bytes: number): string {
|
|
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
return `${bytes} B`;
|
|
}
|