diff --git a/src/common/assets/ui.module.css b/src/common/assets/ui.module.css index 68517ad..81a33c9 100644 --- a/src/common/assets/ui.module.css +++ b/src/common/assets/ui.module.css @@ -62,12 +62,11 @@ .buttonDanger { composes: button; - background: var(--danger, #dc2626); - color: var(--bg); + background: var(--danger); font-weight: 500; &:hover { - background: var(--danger-alt, #b91c1c); + background: var(--danger-alt); } } diff --git a/src/games/hordeseer/assets/manage-workers-modal.module.css b/src/games/hordeseer/assets/manage-workers-modal.module.css index 4fe5a2c..1e1e163 100644 --- a/src/games/hordeseer/assets/manage-workers-modal.module.css +++ b/src/games/hordeseer/assets/manage-workers-modal.module.css @@ -1,67 +1,181 @@ -.modal { - max-width: 480px; - height: fit-content; -} - -.loading { - color: var(--text-muted); - font-size: 14px; - text-align: center; - padding: 20px; -} - -.workerForm { +.list { display: flex; flex-direction: column; - gap: 10px; - background: var(--bg-active); - border-radius: var(--radius); - padding: 12px; + gap: 12px; +} + +.workerCard { + composes: card from '@common/assets/ui.module.css'; + padding: 10px 14px; + gap: 8px; + border: 1px solid var(--border); + background: var(--bg-secondary); +} + +.workerHeader { + display: flex; + align-items: center; + gap: 8px; +} + +.workerInfo { + flex: 1; + min-width: 0; } .workerName { - font-weight: bold; - color: var(--accent-alt); + font-weight: 600; font-size: 14px; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.label { - display: flex; - flex-direction: column; - gap: 4px; +.workerModels { font-size: 12px; color: var(--text-muted); - - input { - width: 100%; - } + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.textarea { - width: 100%; - resize: vertical; +.badges { + display: flex; + gap: 6px; + flex-shrink: 0; +} + +.badge { + composes: badge from '@common/assets/ui.module.css'; + font-size: 11px; + padding: 2px 8px; +} + +.badgeOnline { + composes: badge; + color: var(--accent-alt) !important; + font-weight: 500; +} + +.badgeOffline { + composes: badge; + background: var(--bg-active); + color: var(--text-muted); +} + +.badgeMaintenance { + composes: badge; + background: var(--warning, var(--bg-active)); + color: var(--warning-text, var(--text)); +} + +.fields { + display: flex; + flex-direction: column; + gap: 6px; + + .label { + margin-bottom: 2px; + font-size: 12px; + } } .checkboxLabel { display: flex; - flex-direction: row; align-items: center; - gap: 8px; - font-size: 13px; + gap: 6px; + font-size: 12px; color: var(--text-muted); cursor: pointer; + user-select: none; + white-space: nowrap; } -.formActions { +.actions { display: flex; - flex-direction: row; + align-items: center; gap: 8px; } -.saveBtn { - composes: buttonSecondary from '@common/assets/ui.module.css'; +.spacer { + flex: 1; } -.deleteBtn { - composes: buttonDanger from '@common/assets/ui.module.css'; -} \ No newline at end of file +.deleteBtn { + padding: 8px 16px; + border-radius: var(--radius); + font-size: 14px; + font-family: inherit; + cursor: pointer; + border: 1px solid var(--border); + background: transparent; + color: var(--text); + display: flex; + align-items: center; + gap: 6px; + transition: all var(--transition); + + &:hover:not(:disabled) { + background: var(--danger); + border-color: var(--danger); + color: var(--bg); + } + + &:disabled { + opacity: 0.3; + cursor: not-allowed; + } +} + +.deleteConfirm { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text-muted); +} + +.empty { + composes: empty from '@common/assets/ui.module.css'; + text-align: center; + padding: 32px 0; +} + +.error { + composes: empty; + color: var(--danger); +} + +.toastIcon { + display: inline; + vertical-align: middle; + margin-right: 4px; +} + +.footer { + display: flex; + justify-content: space-between; + align-items: center; +} + +.footerInfo { + font-size: 13px; + color: var(--text-muted); +} + +.footerActions { + display: flex; + gap: 8px; +} + +.toast { + font-size: 13px; + color: var(--success, var(--accent-alt)); + animation: fadeOut 1.5s ease forwards; +} + +@keyframes fadeOut { + 0%, 70% { opacity: 1; } + 100% { opacity: 0; } +} diff --git a/src/games/hordeseer/components/modals/leaderboard-modal.tsx b/src/games/hordeseer/components/modals/leaderboard-modal.tsx index bd4def3..3cf3c61 100644 --- a/src/games/hordeseer/components/modals/leaderboard-modal.tsx +++ b/src/games/hordeseer/components/modals/leaderboard-modal.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "preact/hooks"; +import { useBool } from "@common/hooks/useBool"; import { useHordeState } from "../../contexts/state"; import { fetchLeaderboard, type LeaderboardEntry } from "../../utils/api"; import { formatNumber, formatTime } from "@common/utils"; @@ -23,7 +24,7 @@ export const LeaderboardModal = ({ open, onClose }: Props) => { const kudosPerHour = totalUptime > 0 ? (totalGenerated / totalUptime) * 3600 : 0; const [rows, setRows] = useState<(LeaderboardEntry & { rank: number; diff?: number })[]>([]); - const [loading, setLoading] = useState(false); + const loading = useBool(false); const [error, setError] = useState(null); useEffect(() => { @@ -31,7 +32,7 @@ export const LeaderboardModal = ({ open, onClose }: Props) => { let cancelled = false; const load = async () => { - setLoading(true); + loading.setTrue(); setError(null); try { if (!user) { @@ -84,7 +85,7 @@ export const LeaderboardModal = ({ open, onClose }: Props) => { } catch (e) { if (!cancelled) setError(String(e)); } finally { - if (!cancelled) setLoading(false); + if (!cancelled) loading.setFalse(); } }; @@ -107,9 +108,9 @@ export const LeaderboardModal = ({ open, onClose }: Props) => { return ( - {loading &&

Loading…

} + {loading.value &&

Loading…

} {error &&

{error}

} - {!loading && !error && rows.length > 0 && ( + {!loading.value && !error && rows.length > 0 && ( diff --git a/src/games/hordeseer/components/modals/manage-workers-modal.tsx b/src/games/hordeseer/components/modals/manage-workers-modal.tsx index 5a2dda5..3628f95 100644 --- a/src/games/hordeseer/components/modals/manage-workers-modal.tsx +++ b/src/games/hordeseer/components/modals/manage-workers-modal.tsx @@ -1,8 +1,13 @@ import { useEffect, useState } from "preact/hooks"; -import { useHordeState } from "../../contexts/state"; -import { fetchWorker, updateWorker, deleteWorker, type WorkerData } from "../../utils/api"; +import { Trash2, Check } from "lucide-preact"; +import { useBool } from "@common/hooks/useBool"; +import { useInputState } from "@common/hooks/useInputState"; import { Modal } from "@common/components/Modal"; +import clsx from "clsx"; import styles from "../../assets/manage-workers-modal.module.css"; +import ui from "@common/assets/ui.module.css"; +import { useHordeState } from "../../contexts/state"; +import { deleteWorker, fetchWorker, updateWorker, type WorkerData } from "../../utils/api"; interface Props { open: boolean; @@ -15,111 +20,230 @@ interface WorkerEdit { maintenance_mode: boolean; } +interface WorkerCard { + data: WorkerData; + edit: WorkerEdit; + saving: boolean; + saved: boolean; + deleting: boolean; + confirmDelete: boolean; +} + export const ManageWorkersModal = ({ open, onClose }: Props) => { const { state, dispatch } = useHordeState(); const { user, apiKey } = state; - const [workerDetails, setWorkerDetails] = useState([]); - const [edits, setEdits] = useState>({}); - const [saving, setSaving] = useState>({}); - const [deleting, setDeleting] = useState(null); - const [loading, setLoading] = useState(false); + const [workers, setWorkers] = useState([]); + const [error, setError] = useState(null); + const loading = useBool(false); useEffect(() => { if (!open || !user || !apiKey) return; let cancelled = false; - setLoading(true); + loading.setTrue(); + setError(null); Promise.all(user.worker_ids.map(id => fetchWorker(id, apiKey))) .then(details => { if (cancelled) return; - setWorkerDetails(details); - const initialEdits: Record = {}; - for (const w of details) { - initialEdits[w.id] = { name: w.name, info: w.info ?? '', maintenance_mode: w.maintenance_mode }; - } - setEdits(initialEdits); + const sorted = details.sort((a, b) => { + if (a.online !== b.online) return a.online ? -1 : 1; + return a.name.localeCompare(b.name); + }); + setWorkers(sorted.map(w => ({ + data: w, + edit: { name: w.name, info: w.info ?? '', maintenance_mode: w.maintenance_mode }, + saving: false, + saved: false, + deleting: false, + confirmDelete: false, + }))); }) - .catch(console.error) - .finally(() => { if (!cancelled) setLoading(false); }); + .catch(() => { if (!cancelled) setError('Failed to load workers'); }) + .finally(() => { if (!cancelled) loading.setFalse(); }); return () => { cancelled = true; }; }, [open, user?.worker_ids.join(',')]); const setEdit = (id: string, patch: Partial) => { - setEdits(prev => ({ ...prev, [id]: { ...prev[id], ...patch } })); + setWorkers(prev => prev.map(w => + w.data.id === id + ? { ...w, edit: { ...w.edit, ...patch }, saved: false } + : w + )); }; const save = async (id: string) => { if (!apiKey) return; - setSaving(prev => ({ ...prev, [id]: true })); + setWorkers(prev => prev.map(w => w.data.id === id ? { ...w, saving: true } : w)); try { - const updated = await updateWorker(id, apiKey, edits[id]); - setWorkerDetails(prev => prev.map(w => w.id === id ? updated : w)); - } catch (e) { - console.error('[horde] save worker error:', e); - } finally { - setSaving(prev => ({ ...prev, [id]: false })); + const worker = workers.find(w => w.data.id === id)!; + const updated = await updateWorker(id, apiKey, worker.edit); + setWorkers(prev => prev.map(w => + w.data.id === id + ? { ...w, data: updated, saving: false, saved: true } + : w + )); + setTimeout(() => { + setWorkers(prev => prev.map(w => + w.data.id === id ? { ...w, saved: false } : w + )); + }, 1500); + } catch { + setError('Failed to save worker'); + setWorkers(prev => prev.map(w => + w.data.id === id ? { ...w, saving: false } : w + )); } }; + const startDelete = (id: string) => { + setWorkers(prev => prev.map(w => + w.data.id === id ? { ...w, confirmDelete: true } : w + )); + }; + + const cancelDelete = (id: string) => { + setWorkers(prev => prev.map(w => + w.data.id === id ? { ...w, confirmDelete: false } : w + )); + }; + const confirmDelete = async (id: string) => { - if (!apiKey || !confirm('Delete this worker? This cannot be undone.')) return; - setDeleting(id); + if (!apiKey) return; + setWorkers(prev => prev.map(w => + w.data.id === id ? { ...w, deleting: true, confirmDelete: false } : w + )); try { await deleteWorker(id, apiKey); - setWorkerDetails(prev => prev.filter(w => w.id !== id)); + setWorkers(prev => prev.filter(w => w.data.id !== id)); dispatch({ type: 'SET_USER', user: { ...user!, worker_ids: user!.worker_ids.filter(i => i !== id) } }); - } catch (e) { - console.error('[horde] delete worker error:', e); - } finally { - setDeleting(null); + } catch { + setError('Failed to delete worker'); + setWorkers(prev => prev.map(w => + w.data.id === id ? { ...w, deleting: false } : w + )); } }; + const hasChanges = workers.some(w => + w.edit.name !== w.data.name || + w.edit.info !== (w.data.info ?? '') || + w.edit.maintenance_mode !== w.data.maintenance_mode + ); + + const footer = ( +
+ + {workers.length} worker{workers.length !== 1 ? 's' : ''} + {hasChanges && ' · unsaved changes'} + + {hasChanges && ( +
+ +
+ )} +
+ ); + + const hasChangesForWorker = (w: WorkerCard) => + w.edit.name !== w.data.name || + w.edit.info !== (w.data.info ?? '') || + w.edit.maintenance_mode !== w.data.maintenance_mode; + return ( - - {loading &&

Loading…

} - {!loading && user && workerDetails.map(w => { - const edit = edits[w.id] ?? { name: w.name, info: w.info ?? '', maintenance_mode: w.maintenance_mode }; - return ( -
-
{w.name}
- -