feat(frontend): add Mods page with table view, delete, and cleanup
This commit is contained in:
@@ -7,6 +7,7 @@ import ConfigEditor from './pages/ConfigEditor'
|
||||
import Modlists from './pages/Modlists'
|
||||
import ModlistEditor from './pages/ModlistEditor'
|
||||
import Logs from './pages/Logs'
|
||||
import Mods from './pages/Mods'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -18,6 +19,7 @@ function App() {
|
||||
<Route path="/configs/:name" element={<ConfigEditor />} />
|
||||
<Route path="/modlists" element={<Modlists />} />
|
||||
<Route path="/modlists/:id" element={<ModlistEditor />} />
|
||||
<Route path="/mods" element={<Mods />} />
|
||||
<Route path="/logs" element={<Logs />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -82,6 +82,12 @@ export const steamcmdApi = {
|
||||
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 = {
|
||||
list: () => request<string[]>('/server/logs'),
|
||||
get: (file: string) => fetch(`${BASE}/server/logs/${file}`).then(r => r.text()),
|
||||
|
||||
@@ -6,6 +6,7 @@ const nav = [
|
||||
{ to: '/settings', label: 'Settings', icon: '🖥' },
|
||||
{ to: '/configs', label: 'Configs', icon: '⚙' },
|
||||
{ to: '/modlists', label: 'Modlists', icon: '📦' },
|
||||
{ to: '/mods', label: 'Mods', icon: '🗂' },
|
||||
{ to: '/logs', label: 'Logs', icon: '📋' },
|
||||
]
|
||||
|
||||
|
||||
131
frontend/src/pages/Mods.tsx
Normal file
131
frontend/src/pages/Mods.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -50,3 +50,13 @@ export interface ServerPaths {
|
||||
cfg_dir: string
|
||||
profiles_dir: string
|
||||
}
|
||||
|
||||
export interface ModInfo {
|
||||
id: string
|
||||
name: string
|
||||
source: 'workshop' | 'local'
|
||||
path: string
|
||||
size: number
|
||||
in_use: boolean
|
||||
modlists: string[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user