feat(frontend): add Mods page with table view, delete, and cleanup

This commit is contained in:
MrFastwind
2026-07-06 16:10:32 +02:00
parent c06a9a7981
commit cc7cb0bc56
5 changed files with 151 additions and 1 deletions

View File

@@ -7,6 +7,7 @@ import ConfigEditor from './pages/ConfigEditor'
import Modlists from './pages/Modlists' import Modlists from './pages/Modlists'
import ModlistEditor from './pages/ModlistEditor' import ModlistEditor from './pages/ModlistEditor'
import Logs from './pages/Logs' import Logs from './pages/Logs'
import Mods from './pages/Mods'
function App() { function App() {
return ( return (
@@ -18,6 +19,7 @@ function App() {
<Route path="/configs/:name" element={<ConfigEditor />} /> <Route path="/configs/:name" element={<ConfigEditor />} />
<Route path="/modlists" element={<Modlists />} /> <Route path="/modlists" element={<Modlists />} />
<Route path="/modlists/:id" element={<ModlistEditor />} /> <Route path="/modlists/:id" element={<ModlistEditor />} />
<Route path="/mods" element={<Mods />} />
<Route path="/logs" element={<Logs />} /> <Route path="/logs" element={<Logs />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>

View File

@@ -1,4 +1,4 @@
import type { ServerSettings, ServerPaths, ConfigInfo, Modlist, ModlistListItem, ModEntry } from '../types' import type { ServerSettings, ServerPaths, ConfigInfo, Modlist, ModlistListItem, ModEntry, ModInfo } from '../types'
const BASE = '/api' const BASE = '/api'
@@ -82,6 +82,12 @@ export const steamcmdApi = {
status: () => request<{ running: boolean }>('/server/steamcmd/status'), status: () => request<{ running: boolean }>('/server/steamcmd/status'),
} }
export const modsApi = {
list: () => request<ModInfo[]>('/mods'),
remove: (path: string) => request<void>('/mods', { method: 'DELETE', body: JSON.stringify({ path }) }),
cleanup: () => request<{ deleted: number }>('/mods/cleanup', { method: 'POST' }),
}
export const logsApi = { export const logsApi = {
list: () => request<string[]>('/server/logs'), list: () => request<string[]>('/server/logs'),
get: (file: string) => fetch(`${BASE}/server/logs/${file}`).then(r => r.text()), get: (file: string) => fetch(`${BASE}/server/logs/${file}`).then(r => r.text()),

View File

@@ -6,6 +6,7 @@ const nav = [
{ to: '/settings', label: 'Settings', icon: '🖥' }, { to: '/settings', label: 'Settings', icon: '🖥' },
{ to: '/configs', label: 'Configs', icon: '⚙' }, { to: '/configs', label: 'Configs', icon: '⚙' },
{ to: '/modlists', label: 'Modlists', icon: '📦' }, { to: '/modlists', label: 'Modlists', icon: '📦' },
{ to: '/mods', label: 'Mods', icon: '🗂' },
{ to: '/logs', label: 'Logs', icon: '📋' }, { to: '/logs', label: 'Logs', icon: '📋' },
] ]

131
frontend/src/pages/Mods.tsx Normal file
View File

@@ -0,0 +1,131 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { modsApi } from '../api/client'
import { useState } from 'react'
function fmtSize(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
const v = bytes / Math.pow(1024, i)
return `${v.toFixed(i > 0 ? 1 : 0)} ${units[i]}`
}
export default function Mods() {
const qc = useQueryClient()
const [search, setSearch] = useState('')
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
const { data: mods, isLoading } = useQuery({ queryKey: ['mods'], queryFn: modsApi.list })
const deleteMut = useMutation({
mutationFn: (path: string) => modsApi.remove(path),
onSuccess: () => qc.invalidateQueries({ queryKey: ['mods'] }),
})
const cleanupMut = useMutation({
mutationFn: () => modsApi.cleanup(),
onSuccess: (data) => {
qc.invalidateQueries({ queryKey: ['mods'] })
alert(`Deleted ${data.deleted} orphaned mod(s)`)
},
})
const orphaned = mods?.filter(m => !m.in_use) ?? []
const filtered = mods?.filter(m => {
if (!search) return true
const q = search.toLowerCase()
return m.id.toLowerCase().includes(q) || m.name.toLowerCase().includes(q) || m.source.toLowerCase().includes(q)
})
const handleDelete = (path: string) => {
if (window.confirm('Delete this mod from disk? This cannot be undone.')) {
deleteMut.mutate(path)
}
setConfirmDelete(null)
}
const handleCleanup = () => {
const count = orphaned.length
if (count === 0) return
if (window.confirm(`Delete all ${count} orphaned mod(s) from disk? This cannot be undone.`)) {
cleanupMut.mutate()
}
}
if (isLoading) return <p className="text-neutral-500">Loading...</p>
return (
<div>
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-semibold">Installed Mods</h2>
<button
onClick={handleCleanup}
disabled={orphaned.length === 0 || cleanupMut.isPending}
className="px-4 py-2 bg-red-700 hover:bg-red-600 disabled:opacity-50 rounded text-sm"
>
{cleanupMut.isPending ? 'Deleting...' : `Delete All Orphaned (${orphaned.length})`}
</button>
</div>
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Filter by name or ID..."
className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm mb-4"
/>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-neutral-500 border-b border-neutral-800 text-left">
<th className="pb-2 pr-4">Name</th>
<th className="pb-2 pr-4">Source</th>
<th className="pb-2 pr-4 text-right">Size</th>
<th className="pb-2 pr-4">Status</th>
<th className="pb-2 pr-4">Modlists</th>
<th className="pb-2"></th>
</tr>
</thead>
<tbody>
{filtered?.map((m, i) => (
<tr key={i} className="border-b border-neutral-800/50 hover:bg-neutral-900/50">
<td className="py-2 pr-4 font-mono text-sm">
{m.name || m.id}
{!m.name && m.source === 'workshop' && (
<span className="text-neutral-600 ml-1">({m.id})</span>
)}
</td>
<td className="py-2 pr-4">
<span className={`text-xs px-2 py-0.5 rounded ${m.source === 'workshop' ? 'bg-blue-900 text-blue-300' : 'bg-green-900 text-green-300'}`}>
{m.source}
</span>
</td>
<td className="py-2 pr-4 text-right text-neutral-400">{fmtSize(m.size)}</td>
<td className="py-2 pr-4">
<span className={`text-xs px-2 py-0.5 rounded ${m.in_use ? 'bg-green-900 text-green-300' : 'bg-red-900 text-red-300'}`}>
{m.in_use ? 'Used' : 'Orphaned'}
</span>
</td>
<td className="py-2 pr-4 text-neutral-400 text-xs max-w-xs truncate">
{m.modlists.length > 0 ? m.modlists.join(', ') : '-'}
</td>
<td className="py-2">
<button
onClick={() => handleDelete(m.path)}
disabled={deleteMut.isPending && confirmDelete === m.path}
className="text-xs text-red-400 hover:text-red-300 disabled:opacity-50"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
{filtered?.length === 0 && (
<p className="text-neutral-600 text-sm mt-4">No mods found.</p>
)}
</div>
</div>
)
}

View File

@@ -50,3 +50,13 @@ export interface ServerPaths {
cfg_dir: string cfg_dir: string
profiles_dir: string profiles_dir: string
} }
export interface ModInfo {
id: string
name: string
source: 'workshop' | 'local'
path: string
size: number
in_use: boolean
modlists: string[]
}