mirror of
https://github.com/community-scripts/ProxmoxVE-Local.git
synced 2026-06-06 15:12:22 -04:00
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:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
301
src/app/_components/SyncModal.tsx
Normal file
301
src/app/_components/SyncModal.tsx
Normal 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)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
134
src/app/page.tsx
134
src/app/page.tsx
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user