diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d0638dd..ec25326 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 4967e76..011c45d 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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('/mods'), + remove: (path: string) => request('/mods', { method: 'DELETE', body: JSON.stringify({ path }) }), + cleanup: () => request<{ deleted: number }>('/mods/cleanup', { method: 'POST' }), +} + export const logsApi = { list: () => request('/server/logs'), get: (file: string) => fetch(`${BASE}/server/logs/${file}`).then(r => r.text()), diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 3a6db62..5f08575 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -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: '📋' }, ] diff --git a/frontend/src/pages/Mods.tsx b/frontend/src/pages/Mods.tsx new file mode 100644 index 0000000..27b8c3a --- /dev/null +++ b/frontend/src/pages/Mods.tsx @@ -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(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

Loading...

+ + return ( +
+
+

Installed Mods

+ +
+ + 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" + /> + +
+ + + + + + + + + + + + + {filtered?.map((m, i) => ( + + + + + + + + + ))} + +
NameSourceSizeStatusModlists
+ {m.name || m.id} + {!m.name && m.source === 'workshop' && ( + ({m.id}) + )} + + + {m.source} + + {fmtSize(m.size)} + + {m.in_use ? 'Used' : 'Orphaned'} + + + {m.modlists.length > 0 ? m.modlists.join(', ') : '-'} + + +
+ {filtered?.length === 0 && ( +

No mods found.

+ )} +
+
+ ) +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index fd3efa0..d943463 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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[] +}