Optimize rendering, lazy-load tabs, add sync modal

Performance and UX improvements: memoize computed values in InstalledScriptsTab (scriptsWithStatus, filteredScripts, uniqueServers) and use useCallback/useMemo in ScriptsGrid to avoid unnecessary re-renders. Memoize ScriptCard and ScriptCardList components and tighten their ScriptCard type alias. Introduce a new SyncModal (ResyncButton) component to run/resync scripts with a progress UI and retry/close behavior, and replace the previous ResyncButton import in page.tsx. Lazy-load heavy tab components (Downloaded, Installed, Backups, Generator) with next/dynamic and add a TabSkeleton loader; consolidate tab definitions into a memoized tabs array. Misc: small prop/prop-name fixes and minor JSX formatting adjustments.
This commit is contained in:
CanbiZ (MickLesk)
2026-04-01 16:00:51 +02:00
parent dcb2018366
commit 92e365eb61
6 changed files with 526 additions and 162 deletions

View File

@@ -604,102 +604,124 @@ export function InstalledScriptsTab() {
};
}, []);
const scriptsWithStatus = scripts.map((script) => ({
...script,
container_status: script.container_id
? (containerStatuses.get(script.id) ?? "unknown")
: undefined,
}));
const scriptsWithStatus = useMemo(
() =>
scripts.map((script) => ({
...script,
container_status: script.container_id
? (containerStatuses.get(script.id) ?? "unknown")
: undefined,
})),
[scripts, containerStatuses],
);
// Filter and sort scripts
const filteredScripts = scriptsWithStatus
.filter((script: InstalledScript) => {
const matchesSearch =
script.script_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(script.container_id?.includes(searchTerm) ?? false) ||
(script.server_name?.toLowerCase().includes(searchTerm.toLowerCase()) ??
false);
const filteredScripts = useMemo(
() =>
scriptsWithStatus
.filter((script: InstalledScript) => {
const matchesSearch =
script.script_name
.toLowerCase()
.includes(searchTerm.toLowerCase()) ||
(script.container_id?.includes(searchTerm) ?? false) ||
(script.server_name
?.toLowerCase()
.includes(searchTerm.toLowerCase()) ??
false);
const matchesStatus =
statusFilter === "all" || script.status === statusFilter;
const matchesStatus =
statusFilter === "all" || script.status === statusFilter;
const matchesServer =
serverFilter === "all" ||
(serverFilter === "local" && !script.server_name) ||
script.server_name === serverFilter;
const matchesServer =
serverFilter === "all" ||
(serverFilter === "local" && !script.server_name) ||
script.server_name === serverFilter;
return matchesSearch && matchesStatus && matchesServer;
})
.sort((a: InstalledScript, b: InstalledScript) => {
// Default sorting: group by server, then by container ID
if (sortField === "server_name") {
const aServer = a.server_name ?? "Local";
const bServer = b.server_name ?? "Local";
return matchesSearch && matchesStatus && matchesServer;
})
.sort((a: InstalledScript, b: InstalledScript) => {
// Default sorting: group by server, then by container ID
if (sortField === "server_name") {
const aServer = a.server_name ?? "Local";
const bServer = b.server_name ?? "Local";
// First sort by server name
if (aServer !== bServer) {
return sortDirection === "asc"
? aServer.localeCompare(bServer)
: bServer.localeCompare(aServer);
}
// First sort by server name
if (aServer !== bServer) {
return sortDirection === "asc"
? aServer.localeCompare(bServer)
: bServer.localeCompare(aServer);
}
// If same server, sort by container ID
const aContainerId = a.container_id ?? "";
const bContainerId = b.container_id ?? "";
// If same server, sort by container ID
const aContainerId = a.container_id ?? "";
const bContainerId = b.container_id ?? "";
if (aContainerId !== bContainerId) {
// Convert to numbers for proper numeric sorting
const aNum = parseInt(aContainerId) || 0;
const bNum = parseInt(bContainerId) || 0;
return sortDirection === "asc" ? aNum - bNum : bNum - aNum;
}
if (aContainerId !== bContainerId) {
// Convert to numbers for proper numeric sorting
const aNum = parseInt(aContainerId) || 0;
const bNum = parseInt(bContainerId) || 0;
return sortDirection === "asc" ? aNum - bNum : bNum - aNum;
}
return 0;
}
return 0;
}
// For other sort fields, use the original logic
let aValue: any;
let bValue: any;
// For other sort fields, use the original logic
let aValue: any;
let bValue: any;
switch (sortField) {
case "script_name":
aValue = a.script_name.toLowerCase();
bValue = b.script_name.toLowerCase();
break;
case "container_id":
aValue = a.container_id ?? "";
bValue = b.container_id ?? "";
break;
case "status":
aValue = a.status;
bValue = b.status;
break;
case "installation_date":
aValue = new Date(a.installation_date).getTime();
bValue = new Date(b.installation_date).getTime();
break;
default:
switch (sortField) {
case "script_name":
aValue = a.script_name.toLowerCase();
bValue = b.script_name.toLowerCase();
break;
case "container_id":
aValue = a.container_id ?? "";
bValue = b.container_id ?? "";
break;
case "status":
aValue = a.status;
bValue = b.status;
break;
case "installation_date":
aValue = new Date(a.installation_date).getTime();
bValue = new Date(b.installation_date).getTime();
break;
default:
return 0;
}
if (aValue < bValue) {
return sortDirection === "asc" ? -1 : 1;
}
if (aValue > bValue) {
return sortDirection === "asc" ? 1 : -1;
}
return 0;
}
if (aValue < bValue) {
return sortDirection === "asc" ? -1 : 1;
}
if (aValue > bValue) {
return sortDirection === "asc" ? 1 : -1;
}
return 0;
});
}),
[
scriptsWithStatus,
searchTerm,
statusFilter,
serverFilter,
sortField,
sortDirection,
],
);
// Get unique servers for filter
const uniqueServers: string[] = [];
const seen = new Set<string>();
for (const script of scripts) {
if (script.server_name && !seen.has(String(script.server_name))) {
uniqueServers.push(String(script.server_name));
seen.add(String(script.server_name));
const uniqueServers = useMemo(() => {
const result: string[] = [];
const seen = new Set<string>();
for (const script of scripts) {
if (script.server_name && !seen.has(String(script.server_name))) {
result.push(String(script.server_name));
seen.add(String(script.server_name));
}
}
}
return result;
}, [scripts]);
const handleDeleteScript = (id: number, script?: InstalledScript) => {
const scriptToDelete = script ?? scripts.find((s) => s.id === id);

View File

@@ -1,18 +1,18 @@
"use client";
import { useState } from "react";
import { memo, useState } from "react";
import Image from "next/image";
import type { ScriptCard } from "~/types/script";
import type { ScriptCard as ScriptCardType } from "~/types/script";
import { TypeBadge, UpdateableBadge, DevBadge } from "./Badge";
interface ScriptCardProps {
script: ScriptCard;
onClick: (script: ScriptCard) => void;
script: ScriptCardType;
onClick: (script: ScriptCardType) => void;
isSelected?: boolean;
onToggleSelect?: (slug: string) => void;
}
export function ScriptCard({
export const ScriptCard = memo(function ScriptCard({
script,
onClick,
isSelected = false,
@@ -169,4 +169,4 @@ export function ScriptCard({
</div>
</div>
);
}
});

View File

@@ -1,18 +1,18 @@
"use client";
import { useState } from "react";
import { memo, useState } from "react";
import Image from "next/image";
import type { ScriptCard } from "~/types/script";
import type { ScriptCard as ScriptCardType } from "~/types/script";
import { TypeBadge, UpdateableBadge } from "./Badge";
interface ScriptCardListProps {
script: ScriptCard;
onClick: (script: ScriptCard) => void;
script: ScriptCardType;
onClick: (script: ScriptCardType) => void;
isSelected?: boolean;
onToggleSelect?: (slug: string) => void;
}
export function ScriptCardList({
export const ScriptCardList = memo(function ScriptCardList({
script,
onClick,
isSelected = false,
@@ -281,4 +281,4 @@ export function ScriptCardList({
</div>
</div>
);
}
});

View File

@@ -1,6 +1,6 @@
"use client";
import React, { useState, useRef, useEffect } from "react";
import React, { useState, useRef, useEffect, useCallback } from "react";
import { api } from "~/trpc/react";
import { ScriptCard } from "./ScriptCard";
import { ScriptCardList } from "./ScriptCardList";
@@ -343,7 +343,9 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
if (!script) return false;
// Check if the deduplicated script has categoryNames that include the selected category
return script.categoryNames?.includes(filters.selectedCategory!) ?? false;
return (
script.categoryNames?.includes(filters.selectedCategory!) ?? false
);
});
}
@@ -459,12 +461,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
});
return scripts;
}, [
scriptsWithStatus,
filters,
hasActiveFilters,
newestScripts,
]);
}, [scriptsWithStatus, filters, hasActiveFilters, newestScripts]);
// Calculate filter counts for FilterBar
const filterCounts = React.useMemo(() => {
@@ -493,7 +490,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
};
// Selection management functions
const toggleScriptSelection = (slug: string) => {
const toggleScriptSelection = useCallback((slug: string) => {
setSelectedSlugs((prev) => {
const newSet = new Set(prev);
if (newSet.has(slug)) {
@@ -503,7 +500,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
}
return newSet;
});
};
}, []);
const selectAllVisible = () => {
const visibleSlugs = new Set(
@@ -742,16 +739,16 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
};
}, []);
const handleCardClick = (scriptCard: ScriptCardType) => {
const handleCardClick = useCallback((scriptCard: ScriptCardType) => {
// All scripts are GitHub scripts, open modal
setSelectedSlug(scriptCard.slug);
setIsModalOpen(true);
};
}, []);
const handleCloseModal = () => {
const handleCloseModal = useCallback(() => {
setIsModalOpen(false);
setSelectedSlug(null);
};
}, []);
if (githubLoading || localLoading) {
return (
@@ -833,7 +830,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
categoryCounts={categoryCounts}
categoryDevCounts={categoryDevCounts}
totalScripts={scriptsWithStatus.length}
selectedCategory={selectedCategory}
selectedCategory={filters.selectedCategory}
onCategorySelect={handleCategorySelect}
/>
</div>
@@ -849,13 +846,15 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
updatableCount={filterCounts.updatableCount}
saveFiltersEnabled={saveFiltersEnabled}
isLoadingFilters={isLoadingFilters}
categories={categories}
categoryCounts={categoryCounts}
/>
{/* View Toggle */}
<ViewToggle viewMode={viewMode} onViewModeChange={setViewMode} />
{/* Newest Scripts Carousel - Only show when no search, filters, or category is active */}
{newestScripts.length > 0 && !hasActiveFilters && !selectedCategory && (
{newestScripts.length > 0 && !hasActiveFilters && (
<div className="mb-8">
<div className="bg-card border-l-primary border-border rounded-lg border border-l-4 p-6 shadow-lg">
<div className="mb-4 flex items-center justify-between">
@@ -1164,20 +1163,24 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
</button>
)}
</div>
{(searchQuery || selectedCategory) && (
{(searchQuery || filters.selectedCategory) && (
<div className="text-muted-foreground mt-2 text-center text-sm">
{filteredScripts.length === 0 ? (
<span>
No scripts found
{searchQuery ? ` matching "${searchQuery}"` : ""}
{selectedCategory ? ` in category "${selectedCategory}"` : ""}
{filters.selectedCategory
? ` in category "${filters.selectedCategory}"`
: ""}
</span>
) : (
<span>
Found {filteredScripts.length} script
{filteredScripts.length !== 1 ? "s" : ""}
{searchQuery ? ` matching "${searchQuery}"` : ""}
{selectedCategory ? ` in category "${selectedCategory}"` : ""}
{filters.selectedCategory
? ` in category "${filters.selectedCategory}"`
: ""}
</span>
)}
</div>
@@ -1187,7 +1190,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
{/* Scripts Grid */}
{filteredScripts.length === 0 &&
(filters.searchQuery ||
selectedCategory ||
filters.selectedCategory ||
filters.showUpdatable !== null ||
filters.selectedTypes.length > 0) ? (
<div className="py-12 text-center">
@@ -1221,7 +1224,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
Clear Search
</Button>
)}
{selectedCategory && (
{filters.selectedCategory && (
<Button
onClick={() => handleCategorySelect(null)}
variant="secondary"

View File

@@ -0,0 +1,301 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { api } from "~/trpc/react";
import { Button } from "./ui/button";
import { useRegisterModal, ModalPortal } from "./modal/ModalStackProvider";
import {
RefreshCw,
X,
CheckCircle2,
XCircle,
Loader2,
Download,
SkipForward,
AlertTriangle,
} from "lucide-react";
interface SyncResult {
success: boolean;
message: string;
count?: number;
error?: string;
}
interface SyncModalProps {
isOpen: boolean;
onClose: () => void;
}
function SyncModalContent({ isOpen, onClose }: SyncModalProps) {
const zIndex = useRegisterModal(isOpen, {
id: "sync-modal",
allowEscape: true,
onClose,
});
const [isSyncing, setIsSyncing] = useState(false);
const [result, setResult] = useState<SyncResult | null>(null);
const [elapsedMs, setElapsedMs] = useState(0);
const startTimeRef = useRef<number>(0);
const timerRef = useRef<NodeJS.Timeout | null>(null);
const resyncMutation = api.scripts.resyncScripts.useMutation({
onSuccess: (data) => {
if (timerRef.current) clearInterval(timerRef.current);
setIsSyncing(false);
setResult({
success: data.success,
message: data.message ?? "Sync complete",
count: data.count,
error: data.error ?? undefined,
});
},
onError: (error) => {
if (timerRef.current) clearInterval(timerRef.current);
setIsSyncing(false);
setResult({
success: false,
message: "Sync failed",
error: error.message,
});
},
});
const handleSync = () => {
setIsSyncing(true);
setResult(null);
setElapsedMs(0);
startTimeRef.current = Date.now();
timerRef.current = setInterval(() => {
setElapsedMs(Date.now() - startTimeRef.current);
}, 100);
resyncMutation.mutate();
};
const handleCloseAndReload = () => {
onClose();
if (result?.success) {
window.location.reload();
}
};
useEffect(() => {
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, []);
// Auto-start sync when modal opens
useEffect(() => {
if (isOpen && !isSyncing && !result) {
handleSync();
}
}, [isOpen]);
// Reset state when modal reopens
useEffect(() => {
if (!isOpen) {
setResult(null);
setIsSyncing(false);
setElapsedMs(0);
}
}, [isOpen]);
if (!isOpen) return null;
const formatTime = (ms: number) => {
const seconds = Math.floor(ms / 1000);
const tenths = Math.floor((ms % 1000) / 100);
return `${seconds}.${tenths}s`;
};
// Parse result stats from message
const parseStats = (message: string) => {
const downloaded = /(?<num>\d+)\s*downloaded/.exec(message)?.groups?.num;
const cached = /(?<num>\d+)\s*cached/.exec(message)?.groups?.num;
const errors = /(?<num>\d+)\s*error/.exec(message)?.groups?.num;
return { downloaded, cached, errors };
};
const stats = result?.message ? parseStats(result.message) : null;
return (
<ModalPortal>
<div
className="fixed inset-0 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
style={{ zIndex }}
onClick={(e) => {
if (e.target === e.currentTarget && !isSyncing)
handleCloseAndReload();
}}
>
<div className="bg-card w-full max-w-sm overflow-hidden rounded-2xl border shadow-2xl">
{/* Header */}
<div className="border-border/60 flex items-center justify-between border-b px-5 py-4">
<div className="flex items-center gap-2.5">
<div
className={`flex h-8 w-8 items-center justify-center rounded-lg ${isSyncing ? "bg-primary/10" : result?.success ? "bg-success/10" : result ? "bg-destructive/10" : "bg-primary/10"}`}
>
{isSyncing ? (
<RefreshCw className="text-primary h-4 w-4 animate-spin" />
) : result?.success ? (
<CheckCircle2 className="text-success h-4 w-4" />
) : result ? (
<XCircle className="text-destructive h-4 w-4" />
) : (
<RefreshCw className="text-primary h-4 w-4" />
)}
</div>
<h2 className="text-foreground text-lg font-bold tracking-tight">
{isSyncing
? "Syncing..."
: result?.success
? "Sync Complete"
: result
? "Sync Failed"
: "Sync"}
</h2>
</div>
{!isSyncing && (
<Button
onClick={handleCloseAndReload}
variant="ghost"
size="icon"
className="h-7 w-7 rounded-full"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
{/* Content */}
<div className="p-5">
{isSyncing && (
<div className="space-y-4">
<div className="flex flex-col items-center gap-3 py-4">
<Loader2 className="text-primary h-8 w-8 animate-spin" />
<p className="text-muted-foreground text-sm">
Syncing logo cache with repository...
</p>
<span className="text-muted-foreground/60 font-mono text-xs tabular-nums">
{formatTime(elapsedMs)}
</span>
</div>
<div className="bg-primary/5 h-1.5 w-full overflow-hidden rounded-full">
<div
className="bg-primary h-full animate-pulse rounded-full"
style={{ width: "60%" }}
/>
</div>
</div>
)}
{result && !isSyncing && (
<div className="space-y-4">
{result.success && stats && (
<div className="grid grid-cols-3 gap-3">
<div className="bg-success/5 border-success/20 flex flex-col items-center rounded-xl border p-3">
<Download className="text-success mb-1 h-4 w-4" />
<span className="text-foreground text-lg font-bold tabular-nums">
{stats.downloaded ?? 0}
</span>
<span className="text-muted-foreground text-[0.625rem] tracking-wider uppercase">
Downloaded
</span>
</div>
<div className="bg-muted/30 flex flex-col items-center rounded-xl border p-3">
<SkipForward className="text-muted-foreground mb-1 h-4 w-4" />
<span className="text-foreground text-lg font-bold tabular-nums">
{stats.cached ?? 0}
</span>
<span className="text-muted-foreground text-[0.625rem] tracking-wider uppercase">
Cached
</span>
</div>
<div
className={`flex flex-col items-center rounded-xl border p-3 ${Number(stats.errors) > 0 ? "border-destructive/20 bg-destructive/5" : "bg-muted/30"}`}
>
<AlertTriangle
className={`mb-1 h-4 w-4 ${Number(stats.errors) > 0 ? "text-destructive" : "text-muted-foreground"}`}
/>
<span className="text-foreground text-lg font-bold tabular-nums">
{stats.errors ?? 0}
</span>
<span className="text-muted-foreground text-[0.625rem] tracking-wider uppercase">
Errors
</span>
</div>
</div>
)}
{!result.success && (
<div className="bg-destructive/5 border-destructive/20 rounded-xl border p-4">
<p className="text-destructive text-sm font-medium">
{result.error ?? result.message}
</p>
</div>
)}
<div className="text-muted-foreground flex items-center justify-between text-xs">
<span>Completed in {formatTime(elapsedMs)}</span>
</div>
<div className="flex gap-2">
{result.success ? (
<Button
onClick={handleCloseAndReload}
size="sm"
className="w-full"
>
Done & Reload
</Button>
) : (
<>
<Button
onClick={handleSync}
variant="outline"
size="sm"
className="flex-1 gap-1.5"
>
<RefreshCw className="h-3.5 w-3.5" /> Retry
</Button>
<Button
onClick={handleCloseAndReload}
variant="ghost"
size="sm"
className="flex-1"
>
Close
</Button>
</>
)}
</div>
</div>
)}
</div>
</div>
</div>
</ModalPortal>
);
}
export function ResyncButton() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<Button
onClick={() => setIsOpen(true)}
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground"
title="Sync Scripts"
aria-label="Sync Scripts"
>
<RefreshCw className="h-4 w-4" />
</Button>
<SyncModalContent isOpen={isOpen} onClose={() => setIsOpen(false)} />
</>
);
}

View File

@@ -1,13 +1,10 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { useState, useRef, useEffect, useMemo } from "react";
import dynamic from "next/dynamic";
import Image from "next/image";
import { ScriptsGrid } from "./_components/ScriptsGrid";
import { DownloadedScriptsTab } from "./_components/DownloadedScriptsTab";
import { InstalledScriptsTab } from "./_components/InstalledScriptsTab";
import { BackupsTab } from "./_components/BackupsTab";
import { GeneratorTab } from "./_components/GeneratorTab";
import { ResyncButton } from "./_components/ResyncButton";
import { ResyncButton } from "./_components/SyncModal";
import { Terminal } from "./_components/Terminal";
import { ServerSettingsButton } from "./_components/ServerSettingsButton";
import { SettingsButton } from "./_components/SettingsButton";
@@ -34,6 +31,42 @@ import { useAuth } from "./_components/AuthProvider";
import type { Server } from "~/types/server";
import type { ScriptCard } from "~/types/script";
// Lazy load heavy tab components — only the active tab is loaded
const DownloadedScriptsTab = dynamic(
() =>
import("./_components/DownloadedScriptsTab").then((m) => ({
default: m.DownloadedScriptsTab,
})),
{ loading: () => <TabSkeleton /> },
);
const InstalledScriptsTab = dynamic(
() =>
import("./_components/InstalledScriptsTab").then((m) => ({
default: m.InstalledScriptsTab,
})),
{ loading: () => <TabSkeleton /> },
);
const BackupsTab = dynamic(
() =>
import("./_components/BackupsTab").then((m) => ({ default: m.BackupsTab })),
{ loading: () => <TabSkeleton /> },
);
const GeneratorTab = dynamic(
() =>
import("./_components/GeneratorTab").then((m) => ({
default: m.GeneratorTab,
})),
{ loading: () => <TabSkeleton /> },
);
function TabSkeleton() {
return (
<div className="flex items-center justify-center py-12">
<div className="border-primary h-8 w-8 animate-spin rounded-full border-b-2" />
</div>
);
}
export default function Home() {
const { isAuthenticated, logout } = useAuth();
const [runningScript, setRunningScript] = useState<{
@@ -237,6 +270,52 @@ export default function Home() {
setRunningScript(null);
};
const tabs = useMemo(
() => [
{
key: "scripts" as const,
icon: Package,
label: "Available Scripts",
shortLabel: "Available",
count: scriptCounts.available,
help: "available-scripts",
},
{
key: "downloaded" as const,
icon: HardDrive,
label: "Downloaded Scripts",
shortLabel: "Downloaded",
count: scriptCounts.downloaded,
help: "downloaded-scripts",
},
{
key: "installed" as const,
icon: FolderOpen,
label: "Installed Scripts",
shortLabel: "Installed",
count: scriptCounts.installed,
help: "installed-scripts",
},
{
key: "backups" as const,
icon: Archive,
label: "Backups",
shortLabel: "Backups",
count: scriptCounts.backups,
help: undefined,
},
{
key: "generator" as const,
icon: Wand2,
label: "Generator",
shortLabel: "Generator",
count: undefined,
help: undefined,
},
],
[scriptCounts],
);
return (
<main className="relative min-h-screen">
{/* Sticky Navbar */}
@@ -305,48 +384,7 @@ export default function Home() {
{/* Tab Navigation — pill style */}
<div className="mb-6 sm:mb-8">
<nav className="glass-card-static flex flex-col gap-1 border p-1.5 sm:flex-row sm:gap-0.5">
{[
{
key: "scripts" as const,
icon: Package,
label: "Available Scripts",
shortLabel: "Available",
count: scriptCounts.available,
help: "available-scripts",
},
{
key: "downloaded" as const,
icon: HardDrive,
label: "Downloaded Scripts",
shortLabel: "Downloaded",
count: scriptCounts.downloaded,
help: "downloaded-scripts",
},
{
key: "installed" as const,
icon: FolderOpen,
label: "Installed Scripts",
shortLabel: "Installed",
count: scriptCounts.installed,
help: "installed-scripts",
},
{
key: "backups" as const,
icon: Archive,
label: "Backups",
shortLabel: "Backups",
count: scriptCounts.backups,
help: undefined,
},
{
key: "generator" as const,
icon: Wand2,
label: "Generator",
shortLabel: "Generator",
count: undefined,
help: undefined,
},
].map(({ key, icon: Icon, label, shortLabel, count, help }) => (
{tabs.map(({ key, icon: Icon, label, shortLabel, count, help }) => (
<button
key={key}
onClick={() => setActiveTab(key)}