2026-04-13 00:46:58 -06:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
|
|
|
import { useState, useCallback, useRef, useEffect } from "react";
|
UX polish pass 2: toasts, optimistic updates, mod update detection
- Install sonner; <Toaster> mounted in Providers, auto-tracks theme.
- Toasts replace inline result Alerts across ServerControls, PlayerManager,
BackupManager, ModManager (install/remove/restore/delete/start/stop).
- PlayerManager: optimistic op/deop/whitelist/ban/pardon via onMutate +
rollback; UI updates instantly before RCON round-trip.
- Modrinth search results now show author + "updated Xd ago" with full
timestamp on hover; downloads on its own row.
- New /api/mods/updates endpoint: per-installed-mod Modrinth latest-version
lookup (parallel, 30min memo). Amber "Update available" badge rendered
next to installed mod rows when filenames differ.
- PlayerAvatar + Modrinth icons migrated to next/image (unoptimized, size
hints) — fewer layout shifts.
- Login page surfaces ?error= + NextAuth error codes (CredentialsSignin,
SessionRequired, etc.), preserves callbackUrl, adds autocomplete hints
and role="alert". Wrapped in Suspense per Next 16 requirement.
- Snapshots + backups show relative "Xh ago" with exact timestamp on hover
via new lib/time.ts helper.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 05:11:17 -06:00
|
|
|
import Image from "next/image";
|
|
|
|
|
import { toast } from "sonner";
|
2026-04-13 00:46:58 -06:00
|
|
|
import { CheckCircle2, XCircle, Circle } from "lucide-react";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
|
|
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
import { Separator } from "@/components/ui/separator";
|
UX polish pass 2: toasts, optimistic updates, mod update detection
- Install sonner; <Toaster> mounted in Providers, auto-tracks theme.
- Toasts replace inline result Alerts across ServerControls, PlayerManager,
BackupManager, ModManager (install/remove/restore/delete/start/stop).
- PlayerManager: optimistic op/deop/whitelist/ban/pardon via onMutate +
rollback; UI updates instantly before RCON round-trip.
- Modrinth search results now show author + "updated Xd ago" with full
timestamp on hover; downloads on its own row.
- New /api/mods/updates endpoint: per-installed-mod Modrinth latest-version
lookup (parallel, 30min memo). Amber "Update available" badge rendered
next to installed mod rows when filenames differ.
- PlayerAvatar + Modrinth icons migrated to next/image (unoptimized, size
hints) — fewer layout shifts.
- Login page surfaces ?error= + NextAuth error codes (CredentialsSignin,
SessionRequired, etc.), preserves callbackUrl, adds autocomplete hints
and role="alert". Wrapped in Suspense per Next 16 requirement.
- Snapshots + backups show relative "Xh ago" with exact timestamp on hover
via new lib/time.ts helper.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 05:11:17 -06:00
|
|
|
import { timeAgo } from "@/lib/time";
|
2026-04-13 00:46:58 -06:00
|
|
|
import {
|
|
|
|
|
Card,
|
|
|
|
|
CardContent,
|
|
|
|
|
CardDescription,
|
|
|
|
|
CardHeader,
|
|
|
|
|
CardTitle,
|
|
|
|
|
} from "@/components/ui/card";
|
|
|
|
|
|
|
|
|
|
// ── Types ───────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
type ModSide = "client" | "server" | "both";
|
|
|
|
|
|
|
|
|
|
type ModMeta = {
|
|
|
|
|
modId: string;
|
|
|
|
|
displayName: string;
|
|
|
|
|
version: string;
|
|
|
|
|
filename: string;
|
|
|
|
|
size: string;
|
|
|
|
|
side: ModSide;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type SearchResult = {
|
|
|
|
|
slug: string;
|
|
|
|
|
title: string;
|
|
|
|
|
description: string;
|
|
|
|
|
icon_url: string;
|
|
|
|
|
downloads: number;
|
|
|
|
|
project_id: string;
|
UX polish pass 2: toasts, optimistic updates, mod update detection
- Install sonner; <Toaster> mounted in Providers, auto-tracks theme.
- Toasts replace inline result Alerts across ServerControls, PlayerManager,
BackupManager, ModManager (install/remove/restore/delete/start/stop).
- PlayerManager: optimistic op/deop/whitelist/ban/pardon via onMutate +
rollback; UI updates instantly before RCON round-trip.
- Modrinth search results now show author + "updated Xd ago" with full
timestamp on hover; downloads on its own row.
- New /api/mods/updates endpoint: per-installed-mod Modrinth latest-version
lookup (parallel, 30min memo). Amber "Update available" badge rendered
next to installed mod rows when filenames differ.
- PlayerAvatar + Modrinth icons migrated to next/image (unoptimized, size
hints) — fewer layout shifts.
- Login page surfaces ?error= + NextAuth error codes (CredentialsSignin,
SessionRequired, etc.), preserves callbackUrl, adds autocomplete hints
and role="alert". Wrapped in Suspense per Next 16 requirement.
- Snapshots + backups show relative "Xh ago" with exact timestamp on hover
via new lib/time.ts helper.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 05:11:17 -06:00
|
|
|
author: string;
|
|
|
|
|
date_modified: string;
|
|
|
|
|
follows: number;
|
2026-04-13 00:46:58 -06:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type ModDownload = {
|
|
|
|
|
projectId: string;
|
|
|
|
|
versionId: string;
|
|
|
|
|
title: string;
|
|
|
|
|
filename: string;
|
|
|
|
|
url: string;
|
|
|
|
|
isDependency: boolean;
|
|
|
|
|
alreadyInstalled: boolean;
|
|
|
|
|
side: ModSide;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type ResolveResult = {
|
|
|
|
|
toInstall: ModDownload[];
|
|
|
|
|
skipped: ModDownload[];
|
|
|
|
|
conflicts: string[];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type SnapshotInfo = {
|
|
|
|
|
name: string;
|
|
|
|
|
dirName: string;
|
|
|
|
|
createdAt: string;
|
|
|
|
|
modCount: number;
|
|
|
|
|
mods: string[];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type WizardStep = "idle" | "searching" | "reviewing" | "installing";
|
|
|
|
|
|
|
|
|
|
type TimelineStep = {
|
|
|
|
|
id: string;
|
|
|
|
|
label: string;
|
|
|
|
|
status: "pending" | "active" | "done" | "error";
|
|
|
|
|
message?: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const INSTALL_STEPS_SERVER: Pick<TimelineStep, "id" | "label">[] = [
|
|
|
|
|
{ id: "snapshot", label: "Create snapshot" },
|
|
|
|
|
{ id: "download", label: "Download mods" },
|
|
|
|
|
{ id: "restart", label: "Restart server" },
|
|
|
|
|
{ id: "health", label: "Verify server" },
|
|
|
|
|
{ id: "modpack", label: "Update modpack" },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const INSTALL_STEPS_CLIENT: Pick<TimelineStep, "id" | "label">[] = [
|
|
|
|
|
{ id: "snapshot", label: "Create snapshot" },
|
|
|
|
|
{ id: "download", label: "Download mods" },
|
|
|
|
|
{ id: "modpack", label: "Update modpack" },
|
|
|
|
|
];
|
|
|
|
|
|
Pass 3 first slice: mod update action, error boundaries, a11y, palette
- New POST /api/mods/update SSE route: per-file Modrinth lookup → snapshot →
download latest → swap old jar → restart + verify (if server-side) →
rebuild modpack, with automatic rollback on any failure.
- ModManager: "Update" button next to each mod with an available update,
plus "Update all (N)" in the installed list header. Reuses the existing
install timeline UI (same event shape). SSE reader extracted as
consumeSSE helper.
- Error boundaries: app/error.tsx (scoped), app/admin/error.tsx (admin
subtree retry), app/not-found.tsx, app/global-error.tsx (hard-fail
fallback with inline styles, no app shell dependency).
- A11y sweep: aria-pressed + aria-label on LogViewer level chips and
ModManager side filter; aria-label on admin TabsList; skip-to-content
link in Navbar targeting <main id="main"> on public + admin pages;
role/aria-live on install/update timeline; global Esc in ModManager
clears open confirm prompts and exits search/review wizard steps.
- Command palette (cmdk): global Ctrl/⌘+K dialog mounted in Providers.
Navigate admin tabs, toggle theme, start/stop/restart server, create
backup, re-check mod updates, jump to any cached mod/player/snapshot/
backup. Auth-aware — public users see only Home / Log in / Theme.
- AdminTabs listens to hashchange so palette navigation updates the
active tab live.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 05:30:23 -06:00
|
|
|
const UPDATE_STEPS_SERVER: Pick<TimelineStep, "id" | "label">[] = [
|
|
|
|
|
{ id: "resolve", label: "Resolve latest versions" },
|
|
|
|
|
{ id: "snapshot", label: "Create snapshot" },
|
|
|
|
|
{ id: "download", label: "Download updates" },
|
|
|
|
|
{ id: "restart", label: "Restart server" },
|
|
|
|
|
{ id: "health", label: "Verify server" },
|
|
|
|
|
{ id: "modpack", label: "Update modpack" },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
async function consumeSSE(
|
|
|
|
|
res: Response,
|
|
|
|
|
onStep: (data: { id: string; status: TimelineStep["status"]; message?: string }) => void
|
|
|
|
|
): Promise<{ success: boolean; message: string; installed?: string[]; rolledBack?: boolean }> {
|
|
|
|
|
if (!res.body) throw new Error("No response stream");
|
|
|
|
|
const reader = res.body.getReader();
|
|
|
|
|
const decoder = new TextDecoder();
|
|
|
|
|
let buffer = "";
|
|
|
|
|
let finalResult: { success: boolean; message: string; installed?: string[]; rolledBack?: boolean } | null = null;
|
|
|
|
|
|
|
|
|
|
while (true) {
|
|
|
|
|
const { done, value } = await reader.read();
|
|
|
|
|
if (done) break;
|
|
|
|
|
buffer += decoder.decode(value, { stream: true });
|
|
|
|
|
const frames = buffer.split("\n\n");
|
|
|
|
|
buffer = frames.pop()!;
|
|
|
|
|
for (const frame of frames) {
|
|
|
|
|
if (!frame.trim()) continue;
|
|
|
|
|
const eventMatch = frame.match(/^event:\s*(\w+)/m);
|
|
|
|
|
const dataMatch = frame.match(/^data:\s*(.+)/m);
|
|
|
|
|
if (!eventMatch || !dataMatch) continue;
|
|
|
|
|
const ev = eventMatch[1];
|
|
|
|
|
const data = JSON.parse(dataMatch[1]);
|
|
|
|
|
if (ev === "step") onStep(data);
|
|
|
|
|
else if (ev === "done") finalResult = data;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!finalResult) throw new Error("Stream ended without result");
|
|
|
|
|
return finalResult;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 00:46:58 -06:00
|
|
|
// ── Side Badge ──────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
const sideConfig = {
|
|
|
|
|
client: { label: "Client", className: "border-purple-500/30 text-purple-400" },
|
|
|
|
|
server: { label: "Server", className: "border-orange-500/30 text-orange-400" },
|
|
|
|
|
both: { label: "Both", className: "border-green-500/30 text-green-400" },
|
|
|
|
|
} as const;
|
|
|
|
|
|
|
|
|
|
function SideBadge({ side }: { side: ModSide }) {
|
|
|
|
|
const config = sideConfig[side];
|
|
|
|
|
return (
|
|
|
|
|
<Badge variant="outline" className={`text-xs px-1.5 py-0 ${config.className}`}>
|
|
|
|
|
{config.label}
|
|
|
|
|
</Badge>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Component ───────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export function ModManager() {
|
|
|
|
|
const queryClient = useQueryClient();
|
|
|
|
|
const [step, setStep] = useState<WizardStep>("idle");
|
|
|
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
|
|
|
const [selected, setSelected] = useState<Map<string, SearchResult>>(new Map());
|
|
|
|
|
const [resolved, setResolved] = useState<ResolveResult | null>(null);
|
|
|
|
|
const [installStatus, setInstallStatus] = useState("");
|
|
|
|
|
const [timelineSteps, setTimelineSteps] = useState<TimelineStep[]>([]);
|
|
|
|
|
const [newlyInstalled, setNewlyInstalled] = useState<Set<string>>(new Set());
|
|
|
|
|
const [installResult, setInstallResult] = useState<{
|
|
|
|
|
success: boolean;
|
|
|
|
|
message: string;
|
|
|
|
|
rolledBack?: boolean;
|
|
|
|
|
} | null>(null);
|
|
|
|
|
const [confirmRemove, setConfirmRemove] = useState<string | null>(null);
|
|
|
|
|
const [confirmRestore, setConfirmRestore] = useState<string | null>(null);
|
2026-04-13 00:51:35 -06:00
|
|
|
const [confirmDeleteSnap, setConfirmDeleteSnap] = useState<string | null>(null);
|
UX/UI/perf pass: admin tabs, theme toggle, log polish, mod search, JAR cache
- Admin page split into tabs (Server/Players/Chat/Mods/Backups/Logs) with
hash + localStorage persistence; inactive tabs no longer mount.
- Log viewer: level color-coding, search, level filter chips, auto-scroll
toggle, copy button, visible-line count.
- Installed mods list: search field + side filter (all/both/server/client)
with live count; public ModList gets skeleton + empty states and search.
- Theme toggle with no-flash inline init, localStorage + system preference.
- Layout: full OG / Twitter metadata, title template, keywords,
dual-theme themeColor, metadataBase.
- lib/mods.ts: per-jar mtime+size parse cache (cold 6s -> warm ~45ms on
/api/mods for the full mod list); cache eviction on mod removal.
- ChatBridge polling eased 3s -> 5s with 2s stale window.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 04:58:25 -06:00
|
|
|
const [installedQuery, setInstalledQuery] = useState("");
|
|
|
|
|
const [sideFilter, setSideFilter] = useState<"all" | ModSide>("all");
|
2026-04-13 00:46:58 -06:00
|
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
|
|
|
|
|
|
|
|
|
|
// Installed mods
|
|
|
|
|
const { data: mods = [] } = useQuery<ModMeta[]>({
|
|
|
|
|
queryKey: ["mods"],
|
|
|
|
|
queryFn: () => fetch("/api/mods").then((r) => r.json()),
|
|
|
|
|
staleTime: 30_000,
|
|
|
|
|
});
|
|
|
|
|
|
UX polish pass 2: toasts, optimistic updates, mod update detection
- Install sonner; <Toaster> mounted in Providers, auto-tracks theme.
- Toasts replace inline result Alerts across ServerControls, PlayerManager,
BackupManager, ModManager (install/remove/restore/delete/start/stop).
- PlayerManager: optimistic op/deop/whitelist/ban/pardon via onMutate +
rollback; UI updates instantly before RCON round-trip.
- Modrinth search results now show author + "updated Xd ago" with full
timestamp on hover; downloads on its own row.
- New /api/mods/updates endpoint: per-installed-mod Modrinth latest-version
lookup (parallel, 30min memo). Amber "Update available" badge rendered
next to installed mod rows when filenames differ.
- PlayerAvatar + Modrinth icons migrated to next/image (unoptimized, size
hints) — fewer layout shifts.
- Login page surfaces ?error= + NextAuth error codes (CredentialsSignin,
SessionRequired, etc.), preserves callbackUrl, adds autocomplete hints
and role="alert". Wrapped in Suspense per Next 16 requirement.
- Snapshots + backups show relative "Xh ago" with exact timestamp on hover
via new lib/time.ts helper.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 05:11:17 -06:00
|
|
|
// Updates available (from Modrinth)
|
|
|
|
|
const { data: updates = [] } = useQuery<{ filename: string; latestFilename: string; dateModified: string }[]>({
|
|
|
|
|
queryKey: ["mod-updates"],
|
|
|
|
|
queryFn: () =>
|
|
|
|
|
fetch("/api/mods/updates").then((r) => (r.ok ? r.json() : [])),
|
|
|
|
|
staleTime: 15 * 60 * 1000,
|
|
|
|
|
refetchOnMount: false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const updateMap = new Map(updates.map((u) => [u.filename, u]));
|
|
|
|
|
|
2026-04-13 00:46:58 -06:00
|
|
|
// Search (debounced)
|
|
|
|
|
const [debouncedQuery, setDebouncedQuery] = useState("");
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
|
|
|
debounceRef.current = setTimeout(() => {
|
|
|
|
|
setDebouncedQuery(searchQuery);
|
|
|
|
|
}, 300);
|
|
|
|
|
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
|
|
|
|
|
}, [searchQuery]);
|
|
|
|
|
|
Pass 3 first slice: mod update action, error boundaries, a11y, palette
- New POST /api/mods/update SSE route: per-file Modrinth lookup → snapshot →
download latest → swap old jar → restart + verify (if server-side) →
rebuild modpack, with automatic rollback on any failure.
- ModManager: "Update" button next to each mod with an available update,
plus "Update all (N)" in the installed list header. Reuses the existing
install timeline UI (same event shape). SSE reader extracted as
consumeSSE helper.
- Error boundaries: app/error.tsx (scoped), app/admin/error.tsx (admin
subtree retry), app/not-found.tsx, app/global-error.tsx (hard-fail
fallback with inline styles, no app shell dependency).
- A11y sweep: aria-pressed + aria-label on LogViewer level chips and
ModManager side filter; aria-label on admin TabsList; skip-to-content
link in Navbar targeting <main id="main"> on public + admin pages;
role/aria-live on install/update timeline; global Esc in ModManager
clears open confirm prompts and exits search/review wizard steps.
- Command palette (cmdk): global Ctrl/⌘+K dialog mounted in Providers.
Navigate admin tabs, toggle theme, start/stop/restart server, create
backup, re-check mod updates, jump to any cached mod/player/snapshot/
backup. Auth-aware — public users see only Home / Log in / Theme.
- AdminTabs listens to hashchange so palette navigation updates the
active tab live.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 05:30:23 -06:00
|
|
|
// Global Esc: clear confirm prompts and exit non-installing wizard steps
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const onKey = (e: KeyboardEvent) => {
|
|
|
|
|
if (e.key !== "Escape") return;
|
|
|
|
|
if (confirmRemove || confirmRestore || confirmDeleteSnap) {
|
|
|
|
|
setConfirmRemove(null);
|
|
|
|
|
setConfirmRestore(null);
|
|
|
|
|
setConfirmDeleteSnap(null);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (step === "searching" || step === "reviewing") {
|
|
|
|
|
setStep("idle");
|
|
|
|
|
setSelected(new Map());
|
|
|
|
|
setResolved(null);
|
|
|
|
|
setSearchQuery("");
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
window.addEventListener("keydown", onKey);
|
|
|
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
|
|
|
}, [confirmRemove, confirmRestore, confirmDeleteSnap, step]);
|
|
|
|
|
|
2026-04-13 00:46:58 -06:00
|
|
|
const { data: searchResults = [], isFetching: isSearching } = useQuery<SearchResult[]>({
|
|
|
|
|
queryKey: ["mod-search", debouncedQuery],
|
|
|
|
|
queryFn: () =>
|
|
|
|
|
fetch(`/api/mods/search?q=${encodeURIComponent(debouncedQuery)}`).then((r) =>
|
|
|
|
|
r.json()
|
|
|
|
|
),
|
|
|
|
|
enabled: debouncedQuery.length >= 2 && step === "searching",
|
|
|
|
|
staleTime: 60_000,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Snapshots
|
|
|
|
|
const { data: snapshots = [] } = useQuery<SnapshotInfo[]>({
|
|
|
|
|
queryKey: ["snapshots"],
|
|
|
|
|
queryFn: () => fetch("/api/snapshots").then((r) => r.json()),
|
|
|
|
|
staleTime: 30_000,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Resolve dependencies
|
|
|
|
|
const resolve = useMutation({
|
|
|
|
|
mutationFn: async () => {
|
|
|
|
|
const projectIds = Array.from(selected.keys());
|
|
|
|
|
const titles: Record<string, string> = {};
|
|
|
|
|
selected.forEach((v, k) => (titles[k] = v.title));
|
|
|
|
|
|
|
|
|
|
const res = await fetch("/api/mods/resolve", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ projectIds, titles }),
|
|
|
|
|
});
|
|
|
|
|
return (await res.json()) as ResolveResult;
|
|
|
|
|
},
|
|
|
|
|
onSuccess: (data) => {
|
|
|
|
|
setResolved(data);
|
|
|
|
|
setStep("reviewing");
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Batch install (SSE streaming)
|
|
|
|
|
const install = useMutation({
|
|
|
|
|
mutationFn: async () => {
|
|
|
|
|
if (!resolved) throw new Error("No resolved mods");
|
|
|
|
|
|
|
|
|
|
const modsToInstall = resolved.toInstall.filter((m) => !m.alreadyInstalled);
|
|
|
|
|
const snapshotName = `before-${modsToInstall.map((m) => m.title).join("-").slice(0, 40)}`;
|
|
|
|
|
const hasServerMods = modsToInstall.some((m) => m.side !== "client");
|
|
|
|
|
|
|
|
|
|
// Initialize timeline
|
|
|
|
|
const steps = (hasServerMods ? INSTALL_STEPS_SERVER : INSTALL_STEPS_CLIENT)
|
|
|
|
|
.map((s) => ({ ...s, status: "pending" as const }));
|
|
|
|
|
setTimelineSteps(steps);
|
|
|
|
|
setInstallStatus("Starting installation...");
|
|
|
|
|
|
|
|
|
|
const res = await fetch("/api/mods/batch-install", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ mods: modsToInstall, snapshotName }),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!res.body) throw new Error("No response stream");
|
|
|
|
|
|
|
|
|
|
const reader = res.body.getReader();
|
|
|
|
|
const decoder = new TextDecoder();
|
|
|
|
|
let buffer = "";
|
|
|
|
|
let finalResult: { success: boolean; message: string; installed?: string[]; rolledBack?: boolean } | null = null;
|
|
|
|
|
|
|
|
|
|
while (true) {
|
|
|
|
|
const { done, value } = await reader.read();
|
|
|
|
|
if (done) break;
|
|
|
|
|
buffer += decoder.decode(value, { stream: true });
|
|
|
|
|
|
|
|
|
|
const frames = buffer.split("\n\n");
|
|
|
|
|
buffer = frames.pop()!;
|
|
|
|
|
|
|
|
|
|
for (const frame of frames) {
|
|
|
|
|
if (!frame.trim()) continue;
|
|
|
|
|
const eventMatch = frame.match(/^event:\s*(\w+)/m);
|
|
|
|
|
const dataMatch = frame.match(/^data:\s*(.+)/m);
|
|
|
|
|
if (!eventMatch || !dataMatch) continue;
|
|
|
|
|
|
|
|
|
|
const event = eventMatch[1];
|
|
|
|
|
const data = JSON.parse(dataMatch[1]);
|
|
|
|
|
|
|
|
|
|
if (event === "step") {
|
|
|
|
|
setTimelineSteps((prev) => {
|
|
|
|
|
const exists = prev.some((s) => s.id === data.id);
|
|
|
|
|
if (exists) {
|
|
|
|
|
return prev.map((s) =>
|
|
|
|
|
s.id === data.id ? { ...s, status: data.status, message: data.message } : s
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
// Dynamically add rollback step
|
|
|
|
|
return [...prev, { id: data.id, label: "Rollback", status: data.status, message: data.message }];
|
|
|
|
|
});
|
|
|
|
|
setInstallStatus(data.message);
|
|
|
|
|
} else if (event === "done") {
|
|
|
|
|
finalResult = data;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!finalResult) throw new Error("Stream ended without result");
|
|
|
|
|
return finalResult;
|
|
|
|
|
},
|
|
|
|
|
onSuccess: (data) => {
|
|
|
|
|
setInstallStatus("");
|
|
|
|
|
setInstallResult(data);
|
|
|
|
|
if (data.success) {
|
|
|
|
|
setStep("idle");
|
|
|
|
|
setSelected(new Map());
|
|
|
|
|
setResolved(null);
|
|
|
|
|
setTimelineSteps([]);
|
|
|
|
|
if (data.installed?.length) {
|
|
|
|
|
setNewlyInstalled(new Set(data.installed));
|
|
|
|
|
}
|
UX polish pass 2: toasts, optimistic updates, mod update detection
- Install sonner; <Toaster> mounted in Providers, auto-tracks theme.
- Toasts replace inline result Alerts across ServerControls, PlayerManager,
BackupManager, ModManager (install/remove/restore/delete/start/stop).
- PlayerManager: optimistic op/deop/whitelist/ban/pardon via onMutate +
rollback; UI updates instantly before RCON round-trip.
- Modrinth search results now show author + "updated Xd ago" with full
timestamp on hover; downloads on its own row.
- New /api/mods/updates endpoint: per-installed-mod Modrinth latest-version
lookup (parallel, 30min memo). Amber "Update available" badge rendered
next to installed mod rows when filenames differ.
- PlayerAvatar + Modrinth icons migrated to next/image (unoptimized, size
hints) — fewer layout shifts.
- Login page surfaces ?error= + NextAuth error codes (CredentialsSignin,
SessionRequired, etc.), preserves callbackUrl, adds autocomplete hints
and role="alert". Wrapped in Suspense per Next 16 requirement.
- Snapshots + backups show relative "Xh ago" with exact timestamp on hover
via new lib/time.ts helper.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 05:11:17 -06:00
|
|
|
toast.success(data.message || "Mods installed");
|
|
|
|
|
} else {
|
|
|
|
|
toast.error(data.message || "Install failed", {
|
|
|
|
|
description: data.rolledBack ? "Changes rolled back." : undefined,
|
|
|
|
|
});
|
2026-04-13 00:46:58 -06:00
|
|
|
}
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["mods"] });
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["status"] });
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["snapshots"] });
|
|
|
|
|
},
|
|
|
|
|
onError: (err) => {
|
|
|
|
|
setInstallStatus("");
|
|
|
|
|
setTimelineSteps([]);
|
|
|
|
|
setInstallResult({ success: false, message: err.message });
|
UX polish pass 2: toasts, optimistic updates, mod update detection
- Install sonner; <Toaster> mounted in Providers, auto-tracks theme.
- Toasts replace inline result Alerts across ServerControls, PlayerManager,
BackupManager, ModManager (install/remove/restore/delete/start/stop).
- PlayerManager: optimistic op/deop/whitelist/ban/pardon via onMutate +
rollback; UI updates instantly before RCON round-trip.
- Modrinth search results now show author + "updated Xd ago" with full
timestamp on hover; downloads on its own row.
- New /api/mods/updates endpoint: per-installed-mod Modrinth latest-version
lookup (parallel, 30min memo). Amber "Update available" badge rendered
next to installed mod rows when filenames differ.
- PlayerAvatar + Modrinth icons migrated to next/image (unoptimized, size
hints) — fewer layout shifts.
- Login page surfaces ?error= + NextAuth error codes (CredentialsSignin,
SessionRequired, etc.), preserves callbackUrl, adds autocomplete hints
and role="alert". Wrapped in Suspense per Next 16 requirement.
- Snapshots + backups show relative "Xh ago" with exact timestamp on hover
via new lib/time.ts helper.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 05:11:17 -06:00
|
|
|
toast.error("Install failed", { description: err.message });
|
2026-04-13 00:46:58 -06:00
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
Pass 3 first slice: mod update action, error boundaries, a11y, palette
- New POST /api/mods/update SSE route: per-file Modrinth lookup → snapshot →
download latest → swap old jar → restart + verify (if server-side) →
rebuild modpack, with automatic rollback on any failure.
- ModManager: "Update" button next to each mod with an available update,
plus "Update all (N)" in the installed list header. Reuses the existing
install timeline UI (same event shape). SSE reader extracted as
consumeSSE helper.
- Error boundaries: app/error.tsx (scoped), app/admin/error.tsx (admin
subtree retry), app/not-found.tsx, app/global-error.tsx (hard-fail
fallback with inline styles, no app shell dependency).
- A11y sweep: aria-pressed + aria-label on LogViewer level chips and
ModManager side filter; aria-label on admin TabsList; skip-to-content
link in Navbar targeting <main id="main"> on public + admin pages;
role/aria-live on install/update timeline; global Esc in ModManager
clears open confirm prompts and exits search/review wizard steps.
- Command palette (cmdk): global Ctrl/⌘+K dialog mounted in Providers.
Navigate admin tabs, toggle theme, start/stop/restart server, create
backup, re-check mod updates, jump to any cached mod/player/snapshot/
backup. Auth-aware — public users see only Home / Log in / Theme.
- AdminTabs listens to hashchange so palette navigation updates the
active tab live.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 05:30:23 -06:00
|
|
|
// Update mod(s)
|
|
|
|
|
const updateMods = useMutation({
|
|
|
|
|
mutationFn: async (filenames: string[]) => {
|
|
|
|
|
const hasServerMod = filenames.some((f) => {
|
|
|
|
|
const m = mods.find((x) => x.filename === f);
|
|
|
|
|
return m ? m.side !== "client" : true;
|
|
|
|
|
});
|
|
|
|
|
const steps = (hasServerMod ? UPDATE_STEPS_SERVER : INSTALL_STEPS_CLIENT)
|
|
|
|
|
.map((s) => ({ ...s, status: "pending" as const }));
|
|
|
|
|
setTimelineSteps(steps);
|
|
|
|
|
setInstallStatus("Starting update...");
|
|
|
|
|
|
|
|
|
|
const res = await fetch("/api/mods/update", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ filenames }),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return consumeSSE(res, (data) => {
|
|
|
|
|
setTimelineSteps((prev) => {
|
|
|
|
|
const exists = prev.some((s) => s.id === data.id);
|
|
|
|
|
if (exists) {
|
|
|
|
|
return prev.map((s) =>
|
|
|
|
|
s.id === data.id
|
|
|
|
|
? { ...s, status: data.status, message: data.message }
|
|
|
|
|
: s
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return [
|
|
|
|
|
...prev,
|
|
|
|
|
{
|
|
|
|
|
id: data.id,
|
|
|
|
|
label: data.id === "rollback" ? "Rollback" : data.id,
|
|
|
|
|
status: data.status,
|
|
|
|
|
message: data.message,
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
});
|
|
|
|
|
setInstallStatus(data.message || "");
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
onSuccess: (data) => {
|
|
|
|
|
setInstallStatus("");
|
|
|
|
|
setInstallResult(data);
|
|
|
|
|
if (data.success) {
|
|
|
|
|
setTimelineSteps([]);
|
|
|
|
|
if (data.installed?.length) {
|
|
|
|
|
setNewlyInstalled(new Set(data.installed));
|
|
|
|
|
}
|
|
|
|
|
toast.success(data.message || "Mods updated");
|
|
|
|
|
} else {
|
|
|
|
|
toast.error(data.message || "Update failed", {
|
|
|
|
|
description: data.rolledBack ? "Changes rolled back." : undefined,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["mods"] });
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["mod-updates"] });
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["status"] });
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["snapshots"] });
|
|
|
|
|
},
|
|
|
|
|
onError: (err) => {
|
|
|
|
|
setInstallStatus("");
|
|
|
|
|
setTimelineSteps([]);
|
|
|
|
|
setInstallResult({ success: false, message: err.message });
|
|
|
|
|
toast.error("Update failed", { description: err.message });
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-13 00:46:58 -06:00
|
|
|
// Remove mod
|
|
|
|
|
const removeMod = useMutation({
|
|
|
|
|
mutationFn: async (filename: string) => {
|
|
|
|
|
setConfirmRemove(null);
|
|
|
|
|
const res = await fetch("/api/mods/remove", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ filename }),
|
|
|
|
|
});
|
|
|
|
|
return res.json();
|
|
|
|
|
},
|
|
|
|
|
onSuccess: (data) => {
|
|
|
|
|
setInstallResult(data);
|
UX polish pass 2: toasts, optimistic updates, mod update detection
- Install sonner; <Toaster> mounted in Providers, auto-tracks theme.
- Toasts replace inline result Alerts across ServerControls, PlayerManager,
BackupManager, ModManager (install/remove/restore/delete/start/stop).
- PlayerManager: optimistic op/deop/whitelist/ban/pardon via onMutate +
rollback; UI updates instantly before RCON round-trip.
- Modrinth search results now show author + "updated Xd ago" with full
timestamp on hover; downloads on its own row.
- New /api/mods/updates endpoint: per-installed-mod Modrinth latest-version
lookup (parallel, 30min memo). Amber "Update available" badge rendered
next to installed mod rows when filenames differ.
- PlayerAvatar + Modrinth icons migrated to next/image (unoptimized, size
hints) — fewer layout shifts.
- Login page surfaces ?error= + NextAuth error codes (CredentialsSignin,
SessionRequired, etc.), preserves callbackUrl, adds autocomplete hints
and role="alert". Wrapped in Suspense per Next 16 requirement.
- Snapshots + backups show relative "Xh ago" with exact timestamp on hover
via new lib/time.ts helper.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 05:11:17 -06:00
|
|
|
(data.success ? toast.success : toast.error)(data.message || "Mod removed");
|
2026-04-13 00:46:58 -06:00
|
|
|
queryClient.invalidateQueries({ queryKey: ["mods"] });
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["status"] });
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["snapshots"] });
|
|
|
|
|
},
|
UX polish pass 2: toasts, optimistic updates, mod update detection
- Install sonner; <Toaster> mounted in Providers, auto-tracks theme.
- Toasts replace inline result Alerts across ServerControls, PlayerManager,
BackupManager, ModManager (install/remove/restore/delete/start/stop).
- PlayerManager: optimistic op/deop/whitelist/ban/pardon via onMutate +
rollback; UI updates instantly before RCON round-trip.
- Modrinth search results now show author + "updated Xd ago" with full
timestamp on hover; downloads on its own row.
- New /api/mods/updates endpoint: per-installed-mod Modrinth latest-version
lookup (parallel, 30min memo). Amber "Update available" badge rendered
next to installed mod rows when filenames differ.
- PlayerAvatar + Modrinth icons migrated to next/image (unoptimized, size
hints) — fewer layout shifts.
- Login page surfaces ?error= + NextAuth error codes (CredentialsSignin,
SessionRequired, etc.), preserves callbackUrl, adds autocomplete hints
and role="alert". Wrapped in Suspense per Next 16 requirement.
- Snapshots + backups show relative "Xh ago" with exact timestamp on hover
via new lib/time.ts helper.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 05:11:17 -06:00
|
|
|
onError: (err) => toast.error("Remove failed", { description: err.message }),
|
2026-04-13 00:46:58 -06:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Restore snapshot
|
|
|
|
|
const restoreSnap = useMutation({
|
|
|
|
|
mutationFn: async (dirName: string) => {
|
|
|
|
|
setConfirmRestore(null);
|
|
|
|
|
const res = await fetch("/api/snapshots/restore", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ dirName }),
|
|
|
|
|
});
|
|
|
|
|
return res.json();
|
|
|
|
|
},
|
|
|
|
|
onSuccess: (data) => {
|
|
|
|
|
setInstallResult(data);
|
UX polish pass 2: toasts, optimistic updates, mod update detection
- Install sonner; <Toaster> mounted in Providers, auto-tracks theme.
- Toasts replace inline result Alerts across ServerControls, PlayerManager,
BackupManager, ModManager (install/remove/restore/delete/start/stop).
- PlayerManager: optimistic op/deop/whitelist/ban/pardon via onMutate +
rollback; UI updates instantly before RCON round-trip.
- Modrinth search results now show author + "updated Xd ago" with full
timestamp on hover; downloads on its own row.
- New /api/mods/updates endpoint: per-installed-mod Modrinth latest-version
lookup (parallel, 30min memo). Amber "Update available" badge rendered
next to installed mod rows when filenames differ.
- PlayerAvatar + Modrinth icons migrated to next/image (unoptimized, size
hints) — fewer layout shifts.
- Login page surfaces ?error= + NextAuth error codes (CredentialsSignin,
SessionRequired, etc.), preserves callbackUrl, adds autocomplete hints
and role="alert". Wrapped in Suspense per Next 16 requirement.
- Snapshots + backups show relative "Xh ago" with exact timestamp on hover
via new lib/time.ts helper.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 05:11:17 -06:00
|
|
|
(data.success ? toast.success : toast.error)(data.message || "Snapshot restored");
|
2026-04-13 00:46:58 -06:00
|
|
|
queryClient.invalidateQueries({ queryKey: ["mods"] });
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["status"] });
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["snapshots"] });
|
|
|
|
|
},
|
UX polish pass 2: toasts, optimistic updates, mod update detection
- Install sonner; <Toaster> mounted in Providers, auto-tracks theme.
- Toasts replace inline result Alerts across ServerControls, PlayerManager,
BackupManager, ModManager (install/remove/restore/delete/start/stop).
- PlayerManager: optimistic op/deop/whitelist/ban/pardon via onMutate +
rollback; UI updates instantly before RCON round-trip.
- Modrinth search results now show author + "updated Xd ago" with full
timestamp on hover; downloads on its own row.
- New /api/mods/updates endpoint: per-installed-mod Modrinth latest-version
lookup (parallel, 30min memo). Amber "Update available" badge rendered
next to installed mod rows when filenames differ.
- PlayerAvatar + Modrinth icons migrated to next/image (unoptimized, size
hints) — fewer layout shifts.
- Login page surfaces ?error= + NextAuth error codes (CredentialsSignin,
SessionRequired, etc.), preserves callbackUrl, adds autocomplete hints
and role="alert". Wrapped in Suspense per Next 16 requirement.
- Snapshots + backups show relative "Xh ago" with exact timestamp on hover
via new lib/time.ts helper.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 05:11:17 -06:00
|
|
|
onError: (err) => toast.error("Restore failed", { description: err.message }),
|
2026-04-13 00:46:58 -06:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Delete snapshot
|
|
|
|
|
const deleteSnap = useMutation({
|
|
|
|
|
mutationFn: async (dirName: string) => {
|
|
|
|
|
await fetch("/api/snapshots", {
|
|
|
|
|
method: "DELETE",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ dirName }),
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
onSuccess: () => {
|
UX polish pass 2: toasts, optimistic updates, mod update detection
- Install sonner; <Toaster> mounted in Providers, auto-tracks theme.
- Toasts replace inline result Alerts across ServerControls, PlayerManager,
BackupManager, ModManager (install/remove/restore/delete/start/stop).
- PlayerManager: optimistic op/deop/whitelist/ban/pardon via onMutate +
rollback; UI updates instantly before RCON round-trip.
- Modrinth search results now show author + "updated Xd ago" with full
timestamp on hover; downloads on its own row.
- New /api/mods/updates endpoint: per-installed-mod Modrinth latest-version
lookup (parallel, 30min memo). Amber "Update available" badge rendered
next to installed mod rows when filenames differ.
- PlayerAvatar + Modrinth icons migrated to next/image (unoptimized, size
hints) — fewer layout shifts.
- Login page surfaces ?error= + NextAuth error codes (CredentialsSignin,
SessionRequired, etc.), preserves callbackUrl, adds autocomplete hints
and role="alert". Wrapped in Suspense per Next 16 requirement.
- Snapshots + backups show relative "Xh ago" with exact timestamp on hover
via new lib/time.ts helper.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 05:11:17 -06:00
|
|
|
toast.success("Snapshot deleted");
|
2026-04-13 00:46:58 -06:00
|
|
|
queryClient.invalidateQueries({ queryKey: ["snapshots"] });
|
|
|
|
|
},
|
UX polish pass 2: toasts, optimistic updates, mod update detection
- Install sonner; <Toaster> mounted in Providers, auto-tracks theme.
- Toasts replace inline result Alerts across ServerControls, PlayerManager,
BackupManager, ModManager (install/remove/restore/delete/start/stop).
- PlayerManager: optimistic op/deop/whitelist/ban/pardon via onMutate +
rollback; UI updates instantly before RCON round-trip.
- Modrinth search results now show author + "updated Xd ago" with full
timestamp on hover; downloads on its own row.
- New /api/mods/updates endpoint: per-installed-mod Modrinth latest-version
lookup (parallel, 30min memo). Amber "Update available" badge rendered
next to installed mod rows when filenames differ.
- PlayerAvatar + Modrinth icons migrated to next/image (unoptimized, size
hints) — fewer layout shifts.
- Login page surfaces ?error= + NextAuth error codes (CredentialsSignin,
SessionRequired, etc.), preserves callbackUrl, adds autocomplete hints
and role="alert". Wrapped in Suspense per Next 16 requirement.
- Snapshots + backups show relative "Xh ago" with exact timestamp on hover
via new lib/time.ts helper.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 05:11:17 -06:00
|
|
|
onError: (err) => toast.error("Delete failed", { description: err.message }),
|
2026-04-13 00:46:58 -06:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const toggleSelect = useCallback((result: SearchResult) => {
|
|
|
|
|
setSelected((prev) => {
|
|
|
|
|
const next = new Map(prev);
|
|
|
|
|
if (next.has(result.project_id)) {
|
|
|
|
|
next.delete(result.project_id);
|
|
|
|
|
} else {
|
|
|
|
|
next.set(result.project_id, result);
|
|
|
|
|
}
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
}, []);
|
|
|
|
|
|
Pass 3 first slice: mod update action, error boundaries, a11y, palette
- New POST /api/mods/update SSE route: per-file Modrinth lookup → snapshot →
download latest → swap old jar → restart + verify (if server-side) →
rebuild modpack, with automatic rollback on any failure.
- ModManager: "Update" button next to each mod with an available update,
plus "Update all (N)" in the installed list header. Reuses the existing
install timeline UI (same event shape). SSE reader extracted as
consumeSSE helper.
- Error boundaries: app/error.tsx (scoped), app/admin/error.tsx (admin
subtree retry), app/not-found.tsx, app/global-error.tsx (hard-fail
fallback with inline styles, no app shell dependency).
- A11y sweep: aria-pressed + aria-label on LogViewer level chips and
ModManager side filter; aria-label on admin TabsList; skip-to-content
link in Navbar targeting <main id="main"> on public + admin pages;
role/aria-live on install/update timeline; global Esc in ModManager
clears open confirm prompts and exits search/review wizard steps.
- Command palette (cmdk): global Ctrl/⌘+K dialog mounted in Providers.
Navigate admin tabs, toggle theme, start/stop/restart server, create
backup, re-check mod updates, jump to any cached mod/player/snapshot/
backup. Auth-aware — public users see only Home / Log in / Theme.
- AdminTabs listens to hashchange so palette navigation updates the
active tab live.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 05:30:23 -06:00
|
|
|
const isBusy =
|
|
|
|
|
install.isPending ||
|
|
|
|
|
removeMod.isPending ||
|
|
|
|
|
restoreSnap.isPending ||
|
|
|
|
|
updateMods.isPending;
|
|
|
|
|
const showTimeline =
|
|
|
|
|
(step === "installing" || updateMods.isPending) && timelineSteps.length > 0;
|
2026-04-13 00:46:58 -06:00
|
|
|
|
|
|
|
|
const formatDownloads = (n: number) => {
|
|
|
|
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
|
|
|
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
|
|
|
|
|
return n.toString();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
2026-04-13 00:51:35 -06:00
|
|
|
<div className="space-y-4 sm:space-y-6">
|
2026-04-13 00:46:58 -06:00
|
|
|
{/* ── Mod Manager Card ──────────────────────────────── */}
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div>
|
|
|
|
|
<CardTitle>Mod Manager</CardTitle>
|
|
|
|
|
<CardDescription>
|
|
|
|
|
Search Modrinth, auto-resolve dependencies, install with rollback safety
|
|
|
|
|
</CardDescription>
|
|
|
|
|
</div>
|
|
|
|
|
{step === "idle" && (
|
|
|
|
|
<Button onClick={() => setStep("searching")} disabled={isBusy}>
|
|
|
|
|
Add Mods
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
{step !== "idle" && step !== "installing" && (
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setStep("idle");
|
|
|
|
|
setSelected(new Map());
|
|
|
|
|
setResolved(null);
|
|
|
|
|
setSearchQuery("");
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Cancel
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
|
|
|
|
|
<CardContent className="space-y-4">
|
|
|
|
|
{/* Step indicator */}
|
|
|
|
|
{step !== "idle" && (
|
|
|
|
|
<ol className="flex items-center gap-2 text-xs">
|
|
|
|
|
{[
|
|
|
|
|
{ key: "searching", label: "Search" },
|
|
|
|
|
{ key: "reviewing", label: "Review" },
|
|
|
|
|
{ key: "installing", label: "Install" },
|
|
|
|
|
].map((s, i, arr) => {
|
|
|
|
|
const currentIdx = arr.findIndex((x) => x.key === step);
|
|
|
|
|
const thisIdx = i;
|
|
|
|
|
const state =
|
|
|
|
|
thisIdx < currentIdx ? "done" : thisIdx === currentIdx ? "active" : "pending";
|
|
|
|
|
return (
|
|
|
|
|
<li key={s.key} className="flex items-center gap-2">
|
|
|
|
|
<span
|
|
|
|
|
className={`flex h-5 w-5 items-center justify-center rounded-full border text-[10px] font-semibold ${
|
|
|
|
|
state === "active"
|
|
|
|
|
? "border-primary bg-primary text-primary-foreground"
|
|
|
|
|
: state === "done"
|
|
|
|
|
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-300"
|
|
|
|
|
: "border-border text-muted-foreground"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{thisIdx + 1}
|
|
|
|
|
</span>
|
|
|
|
|
<span
|
|
|
|
|
className={
|
|
|
|
|
state === "active"
|
|
|
|
|
? "font-medium text-foreground"
|
|
|
|
|
: "text-muted-foreground"
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
{s.label}
|
|
|
|
|
</span>
|
|
|
|
|
{i < arr.length - 1 && (
|
|
|
|
|
<span className="w-6 h-px bg-border mx-1" />
|
|
|
|
|
)}
|
|
|
|
|
</li>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</ol>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Result feedback */}
|
|
|
|
|
{installResult && !isBusy && step === "idle" && (
|
|
|
|
|
<Alert
|
|
|
|
|
className={
|
|
|
|
|
installResult.success
|
|
|
|
|
? "border-emerald-500/20 bg-emerald-500/5"
|
|
|
|
|
: "border-red-500/20 bg-red-500/5"
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<AlertDescription
|
|
|
|
|
className={installResult.success ? "text-emerald-300" : "text-red-300"}
|
|
|
|
|
>
|
|
|
|
|
{installResult.message}
|
|
|
|
|
{installResult.rolledBack && (
|
|
|
|
|
<span className="block text-amber-300 mt-1 text-xs">
|
|
|
|
|
Changes were automatically rolled back.
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</AlertDescription>
|
|
|
|
|
</Alert>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* ── Step 1: Search & Select ─────────────────── */}
|
|
|
|
|
{step === "searching" && (
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
<Input
|
|
|
|
|
placeholder="Search mods on Modrinth..."
|
|
|
|
|
value={searchQuery}
|
|
|
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
|
|
|
autoFocus
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{/* Selected queue */}
|
|
|
|
|
{selected.size > 0 && (
|
|
|
|
|
<div className="flex flex-wrap gap-1.5">
|
|
|
|
|
{Array.from(selected.values()).map((s) => (
|
|
|
|
|
<Badge
|
|
|
|
|
key={s.project_id}
|
|
|
|
|
variant="secondary"
|
|
|
|
|
className="gap-1 cursor-pointer hover:bg-destructive/20"
|
|
|
|
|
onClick={() => toggleSelect(s)}
|
|
|
|
|
>
|
|
|
|
|
{s.title}
|
|
|
|
|
<span className="text-muted-foreground">x</span>
|
|
|
|
|
</Badge>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Search results */}
|
|
|
|
|
{isSearching && (
|
|
|
|
|
<p className="text-sm text-muted-foreground">Searching...</p>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{debouncedQuery.length >= 2 && (
|
|
|
|
|
<ul className="space-y-1 max-h-[350px] overflow-y-auto">
|
|
|
|
|
{searchResults.map((result) => {
|
|
|
|
|
const isSelected = selected.has(result.project_id);
|
|
|
|
|
return (
|
|
|
|
|
<li
|
|
|
|
|
key={result.project_id}
|
|
|
|
|
onClick={() => toggleSelect(result)}
|
|
|
|
|
className={`flex items-center gap-3 p-3 rounded-lg cursor-pointer transition ${
|
|
|
|
|
isSelected
|
|
|
|
|
? "bg-primary/10 border border-primary/30"
|
|
|
|
|
: "bg-muted/50 hover:bg-muted border border-transparent"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{result.icon_url ? (
|
UX polish pass 2: toasts, optimistic updates, mod update detection
- Install sonner; <Toaster> mounted in Providers, auto-tracks theme.
- Toasts replace inline result Alerts across ServerControls, PlayerManager,
BackupManager, ModManager (install/remove/restore/delete/start/stop).
- PlayerManager: optimistic op/deop/whitelist/ban/pardon via onMutate +
rollback; UI updates instantly before RCON round-trip.
- Modrinth search results now show author + "updated Xd ago" with full
timestamp on hover; downloads on its own row.
- New /api/mods/updates endpoint: per-installed-mod Modrinth latest-version
lookup (parallel, 30min memo). Amber "Update available" badge rendered
next to installed mod rows when filenames differ.
- PlayerAvatar + Modrinth icons migrated to next/image (unoptimized, size
hints) — fewer layout shifts.
- Login page surfaces ?error= + NextAuth error codes (CredentialsSignin,
SessionRequired, etc.), preserves callbackUrl, adds autocomplete hints
and role="alert". Wrapped in Suspense per Next 16 requirement.
- Snapshots + backups show relative "Xh ago" with exact timestamp on hover
via new lib/time.ts helper.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 05:11:17 -06:00
|
|
|
<Image
|
2026-04-13 00:46:58 -06:00
|
|
|
src={result.icon_url}
|
|
|
|
|
alt=""
|
2026-04-13 00:59:10 -06:00
|
|
|
width={40}
|
|
|
|
|
height={40}
|
UX polish pass 2: toasts, optimistic updates, mod update detection
- Install sonner; <Toaster> mounted in Providers, auto-tracks theme.
- Toasts replace inline result Alerts across ServerControls, PlayerManager,
BackupManager, ModManager (install/remove/restore/delete/start/stop).
- PlayerManager: optimistic op/deop/whitelist/ban/pardon via onMutate +
rollback; UI updates instantly before RCON round-trip.
- Modrinth search results now show author + "updated Xd ago" with full
timestamp on hover; downloads on its own row.
- New /api/mods/updates endpoint: per-installed-mod Modrinth latest-version
lookup (parallel, 30min memo). Amber "Update available" badge rendered
next to installed mod rows when filenames differ.
- PlayerAvatar + Modrinth icons migrated to next/image (unoptimized, size
hints) — fewer layout shifts.
- Login page surfaces ?error= + NextAuth error codes (CredentialsSignin,
SessionRequired, etc.), preserves callbackUrl, adds autocomplete hints
and role="alert". Wrapped in Suspense per Next 16 requirement.
- Snapshots + backups show relative "Xh ago" with exact timestamp on hover
via new lib/time.ts helper.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 05:11:17 -06:00
|
|
|
unoptimized
|
|
|
|
|
className="w-10 h-10 rounded-md shrink-0 bg-muted object-cover"
|
2026-04-13 00:46:58 -06:00
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="w-10 h-10 rounded-md bg-muted shrink-0" />
|
|
|
|
|
)}
|
|
|
|
|
<div className="flex-1 min-w-0">
|
UX polish pass 2: toasts, optimistic updates, mod update detection
- Install sonner; <Toaster> mounted in Providers, auto-tracks theme.
- Toasts replace inline result Alerts across ServerControls, PlayerManager,
BackupManager, ModManager (install/remove/restore/delete/start/stop).
- PlayerManager: optimistic op/deop/whitelist/ban/pardon via onMutate +
rollback; UI updates instantly before RCON round-trip.
- Modrinth search results now show author + "updated Xd ago" with full
timestamp on hover; downloads on its own row.
- New /api/mods/updates endpoint: per-installed-mod Modrinth latest-version
lookup (parallel, 30min memo). Amber "Update available" badge rendered
next to installed mod rows when filenames differ.
- PlayerAvatar + Modrinth icons migrated to next/image (unoptimized, size
hints) — fewer layout shifts.
- Login page surfaces ?error= + NextAuth error codes (CredentialsSignin,
SessionRequired, etc.), preserves callbackUrl, adds autocomplete hints
and role="alert". Wrapped in Suspense per Next 16 requirement.
- Snapshots + backups show relative "Xh ago" with exact timestamp on hover
via new lib/time.ts helper.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 05:11:17 -06:00
|
|
|
<div className="flex items-center gap-2 flex-wrap">
|
2026-04-13 00:46:58 -06:00
|
|
|
<span className="text-sm font-medium truncate">
|
|
|
|
|
{result.title}
|
|
|
|
|
</span>
|
UX polish pass 2: toasts, optimistic updates, mod update detection
- Install sonner; <Toaster> mounted in Providers, auto-tracks theme.
- Toasts replace inline result Alerts across ServerControls, PlayerManager,
BackupManager, ModManager (install/remove/restore/delete/start/stop).
- PlayerManager: optimistic op/deop/whitelist/ban/pardon via onMutate +
rollback; UI updates instantly before RCON round-trip.
- Modrinth search results now show author + "updated Xd ago" with full
timestamp on hover; downloads on its own row.
- New /api/mods/updates endpoint: per-installed-mod Modrinth latest-version
lookup (parallel, 30min memo). Amber "Update available" badge rendered
next to installed mod rows when filenames differ.
- PlayerAvatar + Modrinth icons migrated to next/image (unoptimized, size
hints) — fewer layout shifts.
- Login page surfaces ?error= + NextAuth error codes (CredentialsSignin,
SessionRequired, etc.), preserves callbackUrl, adds autocomplete hints
and role="alert". Wrapped in Suspense per Next 16 requirement.
- Snapshots + backups show relative "Xh ago" with exact timestamp on hover
via new lib/time.ts helper.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 05:11:17 -06:00
|
|
|
{result.author && (
|
|
|
|
|
<span className="text-xs text-muted-foreground shrink-0">
|
|
|
|
|
by {result.author}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
2026-04-13 00:46:58 -06:00
|
|
|
</div>
|
|
|
|
|
<p className="text-xs text-muted-foreground truncate">
|
|
|
|
|
{result.description}
|
|
|
|
|
</p>
|
UX polish pass 2: toasts, optimistic updates, mod update detection
- Install sonner; <Toaster> mounted in Providers, auto-tracks theme.
- Toasts replace inline result Alerts across ServerControls, PlayerManager,
BackupManager, ModManager (install/remove/restore/delete/start/stop).
- PlayerManager: optimistic op/deop/whitelist/ban/pardon via onMutate +
rollback; UI updates instantly before RCON round-trip.
- Modrinth search results now show author + "updated Xd ago" with full
timestamp on hover; downloads on its own row.
- New /api/mods/updates endpoint: per-installed-mod Modrinth latest-version
lookup (parallel, 30min memo). Amber "Update available" badge rendered
next to installed mod rows when filenames differ.
- PlayerAvatar + Modrinth icons migrated to next/image (unoptimized, size
hints) — fewer layout shifts.
- Login page surfaces ?error= + NextAuth error codes (CredentialsSignin,
SessionRequired, etc.), preserves callbackUrl, adds autocomplete hints
and role="alert". Wrapped in Suspense per Next 16 requirement.
- Snapshots + backups show relative "Xh ago" with exact timestamp on hover
via new lib/time.ts helper.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 05:11:17 -06:00
|
|
|
<div className="flex items-center gap-2 mt-0.5 text-[10px] text-muted-foreground/80 tabular-nums">
|
|
|
|
|
<span>{formatDownloads(result.downloads)} downloads</span>
|
|
|
|
|
{result.date_modified && (
|
|
|
|
|
<>
|
|
|
|
|
<span aria-hidden>·</span>
|
|
|
|
|
<span title={new Date(result.date_modified).toLocaleString()}>
|
|
|
|
|
updated {timeAgo(result.date_modified)}
|
|
|
|
|
</span>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-04-13 00:46:58 -06:00
|
|
|
</div>
|
|
|
|
|
{isSelected && (
|
|
|
|
|
<Badge variant="default" className="shrink-0 text-xs">
|
|
|
|
|
Selected
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
</li>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</ul>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Next button */}
|
|
|
|
|
{selected.size > 0 && (
|
|
|
|
|
<div className="flex justify-end">
|
|
|
|
|
<Button
|
|
|
|
|
onClick={() => resolve.mutate()}
|
|
|
|
|
disabled={resolve.isPending}
|
|
|
|
|
>
|
|
|
|
|
{resolve.isPending
|
|
|
|
|
? "Resolving dependencies..."
|
|
|
|
|
: `Next (${selected.size} selected)`}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* ── Step 2: Review & Validate ───────────────── */}
|
|
|
|
|
{step === "reviewing" && resolved && (
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
{/* Conflicts */}
|
|
|
|
|
{resolved.conflicts.length > 0 && (
|
|
|
|
|
<Alert className="border-red-500/20 bg-red-500/5">
|
|
|
|
|
<AlertDescription className="text-red-300">
|
|
|
|
|
<strong>Issues found:</strong>
|
|
|
|
|
<ul className="list-disc pl-4 mt-1 space-y-0.5">
|
|
|
|
|
{resolved.conflicts.map((c, i) => (
|
|
|
|
|
<li key={i}>{c}</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
</AlertDescription>
|
|
|
|
|
</Alert>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Mods to install */}
|
|
|
|
|
{resolved.toInstall.length > 0 && (
|
|
|
|
|
<div>
|
|
|
|
|
<h3 className="text-sm font-semibold mb-2">
|
|
|
|
|
Will be installed ({resolved.toInstall.length})
|
|
|
|
|
</h3>
|
|
|
|
|
<ul className="space-y-1">
|
|
|
|
|
{resolved.toInstall.map((mod) => (
|
|
|
|
|
<li
|
|
|
|
|
key={mod.projectId}
|
|
|
|
|
className="px-3 py-2 rounded-md bg-muted/50"
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
|
|
|
<span className="text-sm font-medium">
|
|
|
|
|
{mod.title}
|
|
|
|
|
</span>
|
|
|
|
|
<SideBadge side={mod.side} />
|
|
|
|
|
{mod.isDependency && (
|
|
|
|
|
<Badge
|
|
|
|
|
variant="outline"
|
|
|
|
|
className="text-xs px-1.5 py-0 border-blue-500/30 text-blue-300"
|
|
|
|
|
>
|
|
|
|
|
dependency
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<p className="text-xs text-muted-foreground truncate">
|
|
|
|
|
{mod.filename}
|
|
|
|
|
</p>
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Skipped (already installed) */}
|
|
|
|
|
{resolved.skipped.length > 0 && (
|
|
|
|
|
<div>
|
|
|
|
|
<h3 className="text-sm font-semibold mb-2 text-muted-foreground">
|
|
|
|
|
Already installed (skipped)
|
|
|
|
|
</h3>
|
|
|
|
|
<ul className="space-y-1">
|
|
|
|
|
{resolved.skipped.map((mod) => (
|
|
|
|
|
<li
|
|
|
|
|
key={mod.projectId}
|
|
|
|
|
className="flex items-center gap-2 px-3 py-2 rounded-md bg-muted/30 opacity-60"
|
|
|
|
|
>
|
|
|
|
|
<span className="text-sm">{mod.title}</span>
|
|
|
|
|
<SideBadge side={mod.side} />
|
|
|
|
|
<Badge variant="secondary" className="text-xs px-1.5 py-0">
|
|
|
|
|
installed
|
|
|
|
|
</Badge>
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Actions */}
|
|
|
|
|
<div className="flex justify-between">
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setStep("searching");
|
|
|
|
|
setResolved(null);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Back
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setStep("installing");
|
|
|
|
|
install.mutate();
|
|
|
|
|
}}
|
|
|
|
|
disabled={
|
|
|
|
|
resolved.toInstall.length === 0 ||
|
|
|
|
|
resolved.conflicts.length > 0
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
Install {resolved.toInstall.length} mod(s)
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
Pass 3 first slice: mod update action, error boundaries, a11y, palette
- New POST /api/mods/update SSE route: per-file Modrinth lookup → snapshot →
download latest → swap old jar → restart + verify (if server-side) →
rebuild modpack, with automatic rollback on any failure.
- ModManager: "Update" button next to each mod with an available update,
plus "Update all (N)" in the installed list header. Reuses the existing
install timeline UI (same event shape). SSE reader extracted as
consumeSSE helper.
- Error boundaries: app/error.tsx (scoped), app/admin/error.tsx (admin
subtree retry), app/not-found.tsx, app/global-error.tsx (hard-fail
fallback with inline styles, no app shell dependency).
- A11y sweep: aria-pressed + aria-label on LogViewer level chips and
ModManager side filter; aria-label on admin TabsList; skip-to-content
link in Navbar targeting <main id="main"> on public + admin pages;
role/aria-live on install/update timeline; global Esc in ModManager
clears open confirm prompts and exits search/review wizard steps.
- Command palette (cmdk): global Ctrl/⌘+K dialog mounted in Providers.
Navigate admin tabs, toggle theme, start/stop/restart server, create
backup, re-check mod updates, jump to any cached mod/player/snapshot/
backup. Auth-aware — public users see only Home / Log in / Theme.
- AdminTabs listens to hashchange so palette navigation updates the
active tab live.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 05:30:23 -06:00
|
|
|
{/* ── Step 3: Installing / Updating (Timeline) ── */}
|
|
|
|
|
{showTimeline && (
|
|
|
|
|
<div className="space-y-1.5 py-2" role="status" aria-live="polite">
|
2026-04-13 00:46:58 -06:00
|
|
|
{timelineSteps.map((s) => (
|
|
|
|
|
<div key={s.id} className="flex items-start gap-3 px-1">
|
|
|
|
|
<div className="mt-0.5 shrink-0">
|
|
|
|
|
{s.status === "done" && (
|
|
|
|
|
<CheckCircle2 className="h-4 w-4 text-emerald-300" />
|
|
|
|
|
)}
|
|
|
|
|
{s.status === "active" && (
|
|
|
|
|
<span className="flex h-4 w-4 items-center justify-center">
|
|
|
|
|
<span className="h-2 w-2 rounded-full bg-blue-400 animate-pulse" />
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
{s.status === "error" && (
|
|
|
|
|
<XCircle className="h-4 w-4 text-red-300" />
|
|
|
|
|
)}
|
|
|
|
|
{s.status === "pending" && (
|
|
|
|
|
<Circle className="h-4 w-4 text-muted-foreground/40" />
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
<p className={`text-sm font-medium ${
|
|
|
|
|
s.status === "done" ? "text-emerald-300" :
|
|
|
|
|
s.status === "active" ? "text-blue-300" :
|
|
|
|
|
s.status === "error" ? "text-red-300" :
|
|
|
|
|
"text-muted-foreground/60"
|
|
|
|
|
}`}>
|
|
|
|
|
{s.label}
|
|
|
|
|
</p>
|
|
|
|
|
{s.message && s.status === "active" && (
|
|
|
|
|
<p className="text-xs text-muted-foreground">{s.message}</p>
|
|
|
|
|
)}
|
|
|
|
|
{s.message && s.status === "error" && (
|
|
|
|
|
<p className="text-xs text-red-300/80">{s.message}</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
<p className="text-xs text-muted-foreground px-1 pt-1">
|
|
|
|
|
Do not close this page.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* ── Installed mods list ─────────────────────── */}
|
|
|
|
|
{step === "idle" && (
|
|
|
|
|
<>
|
|
|
|
|
<Separator />
|
|
|
|
|
<div>
|
UX/UI/perf pass: admin tabs, theme toggle, log polish, mod search, JAR cache
- Admin page split into tabs (Server/Players/Chat/Mods/Backups/Logs) with
hash + localStorage persistence; inactive tabs no longer mount.
- Log viewer: level color-coding, search, level filter chips, auto-scroll
toggle, copy button, visible-line count.
- Installed mods list: search field + side filter (all/both/server/client)
with live count; public ModList gets skeleton + empty states and search.
- Theme toggle with no-flash inline init, localStorage + system preference.
- Layout: full OG / Twitter metadata, title template, keywords,
dual-theme themeColor, metadataBase.
- lib/mods.ts: per-jar mtime+size parse cache (cold 6s -> warm ~45ms on
/api/mods for the full mod list); cache eviction on mod removal.
- ChatBridge polling eased 3s -> 5s with 2s stale window.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 04:58:25 -06:00
|
|
|
<div className="flex items-center justify-between gap-3 mb-3 flex-wrap">
|
Pass 3 first slice: mod update action, error boundaries, a11y, palette
- New POST /api/mods/update SSE route: per-file Modrinth lookup → snapshot →
download latest → swap old jar → restart + verify (if server-side) →
rebuild modpack, with automatic rollback on any failure.
- ModManager: "Update" button next to each mod with an available update,
plus "Update all (N)" in the installed list header. Reuses the existing
install timeline UI (same event shape). SSE reader extracted as
consumeSSE helper.
- Error boundaries: app/error.tsx (scoped), app/admin/error.tsx (admin
subtree retry), app/not-found.tsx, app/global-error.tsx (hard-fail
fallback with inline styles, no app shell dependency).
- A11y sweep: aria-pressed + aria-label on LogViewer level chips and
ModManager side filter; aria-label on admin TabsList; skip-to-content
link in Navbar targeting <main id="main"> on public + admin pages;
role/aria-live on install/update timeline; global Esc in ModManager
clears open confirm prompts and exits search/review wizard steps.
- Command palette (cmdk): global Ctrl/⌘+K dialog mounted in Providers.
Navigate admin tabs, toggle theme, start/stop/restart server, create
backup, re-check mod updates, jump to any cached mod/player/snapshot/
backup. Auth-aware — public users see only Home / Log in / Theme.
- AdminTabs listens to hashchange so palette navigation updates the
active tab live.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 05:30:23 -06:00
|
|
|
<h3 className="text-sm font-semibold flex items-center gap-2">
|
|
|
|
|
{updates.length > 0 && (
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() =>
|
|
|
|
|
updateMods.mutate(updates.map((u) => u.filename))
|
|
|
|
|
}
|
|
|
|
|
disabled={isBusy}
|
|
|
|
|
className="text-xs h-8 border-amber-500/40 text-amber-300 hover:bg-amber-500/10"
|
|
|
|
|
>
|
|
|
|
|
Update all ({updates.length})
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
<span>Installed Mods ({(() => {
|
UX/UI/perf pass: admin tabs, theme toggle, log polish, mod search, JAR cache
- Admin page split into tabs (Server/Players/Chat/Mods/Backups/Logs) with
hash + localStorage persistence; inactive tabs no longer mount.
- Log viewer: level color-coding, search, level filter chips, auto-scroll
toggle, copy button, visible-line count.
- Installed mods list: search field + side filter (all/both/server/client)
with live count; public ModList gets skeleton + empty states and search.
- Theme toggle with no-flash inline init, localStorage + system preference.
- Layout: full OG / Twitter metadata, title template, keywords,
dual-theme themeColor, metadataBase.
- lib/mods.ts: per-jar mtime+size parse cache (cold 6s -> warm ~45ms on
/api/mods for the full mod list); cache eviction on mod removal.
- ChatBridge polling eased 3s -> 5s with 2s stale window.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 04:58:25 -06:00
|
|
|
const q = installedQuery.trim().toLowerCase();
|
|
|
|
|
const count = mods.filter((m) =>
|
|
|
|
|
(sideFilter === "all" || m.side === sideFilter) &&
|
|
|
|
|
(!q ||
|
|
|
|
|
m.displayName.toLowerCase().includes(q) ||
|
|
|
|
|
m.modId.toLowerCase().includes(q) ||
|
|
|
|
|
m.filename.toLowerCase().includes(q))
|
|
|
|
|
).length;
|
|
|
|
|
return count === mods.length ? mods.length : `${count} / ${mods.length}`;
|
Pass 3 first slice: mod update action, error boundaries, a11y, palette
- New POST /api/mods/update SSE route: per-file Modrinth lookup → snapshot →
download latest → swap old jar → restart + verify (if server-side) →
rebuild modpack, with automatic rollback on any failure.
- ModManager: "Update" button next to each mod with an available update,
plus "Update all (N)" in the installed list header. Reuses the existing
install timeline UI (same event shape). SSE reader extracted as
consumeSSE helper.
- Error boundaries: app/error.tsx (scoped), app/admin/error.tsx (admin
subtree retry), app/not-found.tsx, app/global-error.tsx (hard-fail
fallback with inline styles, no app shell dependency).
- A11y sweep: aria-pressed + aria-label on LogViewer level chips and
ModManager side filter; aria-label on admin TabsList; skip-to-content
link in Navbar targeting <main id="main"> on public + admin pages;
role/aria-live on install/update timeline; global Esc in ModManager
clears open confirm prompts and exits search/review wizard steps.
- Command palette (cmdk): global Ctrl/⌘+K dialog mounted in Providers.
Navigate admin tabs, toggle theme, start/stop/restart server, create
backup, re-check mod updates, jump to any cached mod/player/snapshot/
backup. Auth-aware — public users see only Home / Log in / Theme.
- AdminTabs listens to hashchange so palette navigation updates the
active tab live.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 05:30:23 -06:00
|
|
|
})()})</span>
|
UX/UI/perf pass: admin tabs, theme toggle, log polish, mod search, JAR cache
- Admin page split into tabs (Server/Players/Chat/Mods/Backups/Logs) with
hash + localStorage persistence; inactive tabs no longer mount.
- Log viewer: level color-coding, search, level filter chips, auto-scroll
toggle, copy button, visible-line count.
- Installed mods list: search field + side filter (all/both/server/client)
with live count; public ModList gets skeleton + empty states and search.
- Theme toggle with no-flash inline init, localStorage + system preference.
- Layout: full OG / Twitter metadata, title template, keywords,
dual-theme themeColor, metadataBase.
- lib/mods.ts: per-jar mtime+size parse cache (cold 6s -> warm ~45ms on
/api/mods for the full mod list); cache eviction on mod removal.
- ChatBridge polling eased 3s -> 5s with 2s stale window.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 04:58:25 -06:00
|
|
|
</h3>
|
|
|
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
|
|
|
<div className="flex gap-1">
|
|
|
|
|
{(["all", "both", "server", "client"] as const).map((s) => (
|
|
|
|
|
<button
|
|
|
|
|
key={s}
|
|
|
|
|
onClick={() => setSideFilter(s)}
|
Pass 3 first slice: mod update action, error boundaries, a11y, palette
- New POST /api/mods/update SSE route: per-file Modrinth lookup → snapshot →
download latest → swap old jar → restart + verify (if server-side) →
rebuild modpack, with automatic rollback on any failure.
- ModManager: "Update" button next to each mod with an available update,
plus "Update all (N)" in the installed list header. Reuses the existing
install timeline UI (same event shape). SSE reader extracted as
consumeSSE helper.
- Error boundaries: app/error.tsx (scoped), app/admin/error.tsx (admin
subtree retry), app/not-found.tsx, app/global-error.tsx (hard-fail
fallback with inline styles, no app shell dependency).
- A11y sweep: aria-pressed + aria-label on LogViewer level chips and
ModManager side filter; aria-label on admin TabsList; skip-to-content
link in Navbar targeting <main id="main"> on public + admin pages;
role/aria-live on install/update timeline; global Esc in ModManager
clears open confirm prompts and exits search/review wizard steps.
- Command palette (cmdk): global Ctrl/⌘+K dialog mounted in Providers.
Navigate admin tabs, toggle theme, start/stop/restart server, create
backup, re-check mod updates, jump to any cached mod/player/snapshot/
backup. Auth-aware — public users see only Home / Log in / Theme.
- AdminTabs listens to hashchange so palette navigation updates the
active tab live.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 05:30:23 -06:00
|
|
|
aria-pressed={sideFilter === s}
|
|
|
|
|
aria-label={`Filter by ${s}`}
|
UX/UI/perf pass: admin tabs, theme toggle, log polish, mod search, JAR cache
- Admin page split into tabs (Server/Players/Chat/Mods/Backups/Logs) with
hash + localStorage persistence; inactive tabs no longer mount.
- Log viewer: level color-coding, search, level filter chips, auto-scroll
toggle, copy button, visible-line count.
- Installed mods list: search field + side filter (all/both/server/client)
with live count; public ModList gets skeleton + empty states and search.
- Theme toggle with no-flash inline init, localStorage + system preference.
- Layout: full OG / Twitter metadata, title template, keywords,
dual-theme themeColor, metadataBase.
- lib/mods.ts: per-jar mtime+size parse cache (cold 6s -> warm ~45ms on
/api/mods for the full mod list); cache eviction on mod removal.
- ChatBridge polling eased 3s -> 5s with 2s stale window.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 04:58:25 -06:00
|
|
|
className={`text-[10px] font-semibold uppercase rounded px-2 py-1 border transition ${
|
|
|
|
|
sideFilter === s
|
|
|
|
|
? "border-primary/40 bg-primary/10 text-primary"
|
|
|
|
|
: "border-transparent text-muted-foreground hover:text-foreground"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{s}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
<Input
|
|
|
|
|
placeholder="Search installed..."
|
|
|
|
|
value={installedQuery}
|
|
|
|
|
onChange={(e) => setInstalledQuery(e.target.value)}
|
|
|
|
|
className="h-8 text-sm max-w-[200px]"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-04-13 00:46:58 -06:00
|
|
|
<ul className="max-h-[400px] overflow-y-auto space-y-1">
|
UX/UI/perf pass: admin tabs, theme toggle, log polish, mod search, JAR cache
- Admin page split into tabs (Server/Players/Chat/Mods/Backups/Logs) with
hash + localStorage persistence; inactive tabs no longer mount.
- Log viewer: level color-coding, search, level filter chips, auto-scroll
toggle, copy button, visible-line count.
- Installed mods list: search field + side filter (all/both/server/client)
with live count; public ModList gets skeleton + empty states and search.
- Theme toggle with no-flash inline init, localStorage + system preference.
- Layout: full OG / Twitter metadata, title template, keywords,
dual-theme themeColor, metadataBase.
- lib/mods.ts: per-jar mtime+size parse cache (cold 6s -> warm ~45ms on
/api/mods for the full mod list); cache eviction on mod removal.
- ChatBridge polling eased 3s -> 5s with 2s stale window.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 04:58:25 -06:00
|
|
|
{mods
|
|
|
|
|
.filter((m) => {
|
|
|
|
|
if (sideFilter !== "all" && m.side !== sideFilter) return false;
|
|
|
|
|
const q = installedQuery.trim().toLowerCase();
|
|
|
|
|
if (!q) return true;
|
|
|
|
|
return (
|
|
|
|
|
m.displayName.toLowerCase().includes(q) ||
|
|
|
|
|
m.modId.toLowerCase().includes(q) ||
|
|
|
|
|
m.filename.toLowerCase().includes(q)
|
|
|
|
|
);
|
|
|
|
|
})
|
|
|
|
|
.map((mod) => (
|
2026-04-13 00:46:58 -06:00
|
|
|
<li
|
|
|
|
|
key={mod.filename}
|
|
|
|
|
className="flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-muted/50 group"
|
|
|
|
|
>
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span className="text-sm font-medium truncate">
|
|
|
|
|
{mod.displayName}
|
|
|
|
|
</span>
|
|
|
|
|
{mod.version && (
|
|
|
|
|
<Badge
|
|
|
|
|
variant="secondary"
|
|
|
|
|
className="text-xs px-1.5 py-0"
|
|
|
|
|
>
|
|
|
|
|
{mod.version}
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
<SideBadge side={mod.side} />
|
|
|
|
|
{newlyInstalled.has(mod.filename) && (
|
|
|
|
|
<Badge variant="outline" className="text-xs px-1.5 py-0 border-emerald-500/30 text-emerald-300">
|
|
|
|
|
New
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
UX polish pass 2: toasts, optimistic updates, mod update detection
- Install sonner; <Toaster> mounted in Providers, auto-tracks theme.
- Toasts replace inline result Alerts across ServerControls, PlayerManager,
BackupManager, ModManager (install/remove/restore/delete/start/stop).
- PlayerManager: optimistic op/deop/whitelist/ban/pardon via onMutate +
rollback; UI updates instantly before RCON round-trip.
- Modrinth search results now show author + "updated Xd ago" with full
timestamp on hover; downloads on its own row.
- New /api/mods/updates endpoint: per-installed-mod Modrinth latest-version
lookup (parallel, 30min memo). Amber "Update available" badge rendered
next to installed mod rows when filenames differ.
- PlayerAvatar + Modrinth icons migrated to next/image (unoptimized, size
hints) — fewer layout shifts.
- Login page surfaces ?error= + NextAuth error codes (CredentialsSignin,
SessionRequired, etc.), preserves callbackUrl, adds autocomplete hints
and role="alert". Wrapped in Suspense per Next 16 requirement.
- Snapshots + backups show relative "Xh ago" with exact timestamp on hover
via new lib/time.ts helper.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 05:11:17 -06:00
|
|
|
{updateMap.has(mod.filename) && (
|
|
|
|
|
<Badge
|
|
|
|
|
variant="outline"
|
|
|
|
|
className="text-xs px-1.5 py-0 border-amber-500/40 text-amber-300"
|
|
|
|
|
title={`Latest: ${updateMap.get(mod.filename)?.latestFilename}`}
|
|
|
|
|
>
|
|
|
|
|
Update available
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
2026-04-13 00:46:58 -06:00
|
|
|
</div>
|
|
|
|
|
<p className="text-xs text-muted-foreground truncate">
|
|
|
|
|
{mod.filename} — {mod.size}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{confirmRemove === mod.filename ? (
|
|
|
|
|
<div className="flex gap-1 shrink-0">
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="destructive"
|
|
|
|
|
onClick={() => removeMod.mutate(mod.filename)}
|
|
|
|
|
disabled={isBusy}
|
|
|
|
|
className="text-xs h-9"
|
|
|
|
|
>
|
|
|
|
|
Confirm
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
onClick={() => setConfirmRemove(null)}
|
|
|
|
|
className="text-xs h-9"
|
|
|
|
|
>
|
|
|
|
|
Cancel
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
Pass 3 first slice: mod update action, error boundaries, a11y, palette
- New POST /api/mods/update SSE route: per-file Modrinth lookup → snapshot →
download latest → swap old jar → restart + verify (if server-side) →
rebuild modpack, with automatic rollback on any failure.
- ModManager: "Update" button next to each mod with an available update,
plus "Update all (N)" in the installed list header. Reuses the existing
install timeline UI (same event shape). SSE reader extracted as
consumeSSE helper.
- Error boundaries: app/error.tsx (scoped), app/admin/error.tsx (admin
subtree retry), app/not-found.tsx, app/global-error.tsx (hard-fail
fallback with inline styles, no app shell dependency).
- A11y sweep: aria-pressed + aria-label on LogViewer level chips and
ModManager side filter; aria-label on admin TabsList; skip-to-content
link in Navbar targeting <main id="main"> on public + admin pages;
role/aria-live on install/update timeline; global Esc in ModManager
clears open confirm prompts and exits search/review wizard steps.
- Command palette (cmdk): global Ctrl/⌘+K dialog mounted in Providers.
Navigate admin tabs, toggle theme, start/stop/restart server, create
backup, re-check mod updates, jump to any cached mod/player/snapshot/
backup. Auth-aware — public users see only Home / Log in / Theme.
- AdminTabs listens to hashchange so palette navigation updates the
active tab live.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 05:30:23 -06:00
|
|
|
<div className="flex gap-1 shrink-0">
|
|
|
|
|
{updateMap.has(mod.filename) && (
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => updateMods.mutate([mod.filename])}
|
|
|
|
|
disabled={isBusy}
|
|
|
|
|
className="text-xs h-9 border-amber-500/40 text-amber-300 hover:bg-amber-500/10"
|
|
|
|
|
>
|
|
|
|
|
Update
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
onClick={() => setConfirmRemove(mod.filename)}
|
|
|
|
|
disabled={isBusy}
|
|
|
|
|
className="text-xs h-9 text-muted-foreground sm:opacity-0 sm:group-hover:opacity-100 transition"
|
|
|
|
|
>
|
|
|
|
|
Remove
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
2026-04-13 00:46:58 -06:00
|
|
|
)}
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
{/* ── Snapshots Card ────────────────────────────────── */}
|
|
|
|
|
{step === "idle" && snapshots.length > 0 && (
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<CardTitle className="text-base">Snapshots</CardTitle>
|
|
|
|
|
<CardDescription>
|
|
|
|
|
Restore a previous mod configuration
|
|
|
|
|
</CardDescription>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
<ul className="space-y-1">
|
|
|
|
|
{snapshots.map((snap) => (
|
|
|
|
|
<li
|
|
|
|
|
key={snap.dirName}
|
|
|
|
|
className="flex items-center justify-between px-3 py-2.5 rounded-md bg-muted/50 group"
|
|
|
|
|
>
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span className="text-sm font-medium">{snap.name}</span>
|
|
|
|
|
<Badge
|
|
|
|
|
variant="secondary"
|
|
|
|
|
className="text-xs px-1.5 py-0"
|
|
|
|
|
>
|
|
|
|
|
{snap.modCount} mods
|
|
|
|
|
</Badge>
|
|
|
|
|
</div>
|
UX polish pass 2: toasts, optimistic updates, mod update detection
- Install sonner; <Toaster> mounted in Providers, auto-tracks theme.
- Toasts replace inline result Alerts across ServerControls, PlayerManager,
BackupManager, ModManager (install/remove/restore/delete/start/stop).
- PlayerManager: optimistic op/deop/whitelist/ban/pardon via onMutate +
rollback; UI updates instantly before RCON round-trip.
- Modrinth search results now show author + "updated Xd ago" with full
timestamp on hover; downloads on its own row.
- New /api/mods/updates endpoint: per-installed-mod Modrinth latest-version
lookup (parallel, 30min memo). Amber "Update available" badge rendered
next to installed mod rows when filenames differ.
- PlayerAvatar + Modrinth icons migrated to next/image (unoptimized, size
hints) — fewer layout shifts.
- Login page surfaces ?error= + NextAuth error codes (CredentialsSignin,
SessionRequired, etc.), preserves callbackUrl, adds autocomplete hints
and role="alert". Wrapped in Suspense per Next 16 requirement.
- Snapshots + backups show relative "Xh ago" with exact timestamp on hover
via new lib/time.ts helper.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 05:11:17 -06:00
|
|
|
<p
|
|
|
|
|
className="text-xs text-muted-foreground"
|
|
|
|
|
title={new Date(snap.createdAt).toLocaleString()}
|
|
|
|
|
>
|
|
|
|
|
{timeAgo(snap.createdAt)}
|
2026-04-13 00:46:58 -06:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex gap-1 shrink-0 flex-wrap justify-end">
|
|
|
|
|
{confirmRestore === snap.dirName ? (
|
|
|
|
|
<>
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="default"
|
|
|
|
|
onClick={() => restoreSnap.mutate(snap.dirName)}
|
|
|
|
|
disabled={isBusy}
|
|
|
|
|
className="text-xs h-9"
|
|
|
|
|
>
|
2026-04-13 00:51:35 -06:00
|
|
|
Confirm Restore
|
2026-04-13 00:46:58 -06:00
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
onClick={() => setConfirmRestore(null)}
|
|
|
|
|
className="text-xs h-9"
|
|
|
|
|
>
|
|
|
|
|
Cancel
|
|
|
|
|
</Button>
|
|
|
|
|
</>
|
2026-04-13 00:51:35 -06:00
|
|
|
) : confirmDeleteSnap === snap.dirName ? (
|
|
|
|
|
<>
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="destructive"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
deleteSnap.mutate(snap.dirName);
|
|
|
|
|
setConfirmDeleteSnap(null);
|
|
|
|
|
}}
|
|
|
|
|
disabled={isBusy}
|
|
|
|
|
className="text-xs h-9"
|
|
|
|
|
>
|
|
|
|
|
Confirm Delete
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
onClick={() => setConfirmDeleteSnap(null)}
|
|
|
|
|
className="text-xs h-9"
|
|
|
|
|
>
|
|
|
|
|
Cancel
|
|
|
|
|
</Button>
|
|
|
|
|
</>
|
2026-04-13 00:46:58 -06:00
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => setConfirmRestore(snap.dirName)}
|
|
|
|
|
disabled={isBusy}
|
|
|
|
|
className="text-xs h-9 sm:opacity-0 sm:group-hover:opacity-100 transition"
|
|
|
|
|
>
|
|
|
|
|
Restore
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="ghost"
|
2026-04-13 00:51:35 -06:00
|
|
|
onClick={() => setConfirmDeleteSnap(snap.dirName)}
|
2026-04-13 00:46:58 -06:00
|
|
|
disabled={isBusy}
|
|
|
|
|
className="text-xs h-9 text-muted-foreground sm:opacity-0 sm:group-hover:opacity-100 transition"
|
|
|
|
|
>
|
|
|
|
|
Delete
|
|
|
|
|
</Button>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|