mirror of
https://github.com/community-scripts/ProxmoxVE-Local.git
synced 2026-07-04 04:42:11 -04:00
1426 lines
50 KiB
TypeScript
1426 lines
50 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useRef, useEffect, useCallback } from "react";
|
|
import { api } from "~/trpc/react";
|
|
import { ScriptCard } from "./ScriptCard";
|
|
import { ScriptCardList } from "./ScriptCardList";
|
|
import { ScriptDetailModal } from "./ScriptDetailModal";
|
|
import { CategorySidebar } from "./CategorySidebar";
|
|
import { FilterBar, type FilterState } from "./FilterBar";
|
|
import { ViewToggle } from "./ViewToggle";
|
|
import { Button } from "./ui/button";
|
|
import { Clock } from "lucide-react";
|
|
import type { ScriptCard as ScriptCardType } from "~/types/script";
|
|
import { getDefaultFilters, mergeFiltersWithDefaults } from "./filterUtils";
|
|
import { useShell } from "./ShellContext";
|
|
|
|
export function ScriptsGrid() {
|
|
const [selectedSlug, setSelectedSlug] = useState<string | null>(null);
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [viewMode, setViewMode] = useState<"card" | "list">("card");
|
|
const [selectedSlugs, setSelectedSlugs] = useState<Set<string>>(new Set());
|
|
const [downloadProgress, setDownloadProgress] = useState<{
|
|
current: number;
|
|
total: number;
|
|
currentScript: string;
|
|
failed: Array<{ slug: string; error: string }>;
|
|
} | null>(null);
|
|
const [filters, setFilters] = useState<FilterState>(getDefaultFilters());
|
|
const [saveFiltersEnabled, setSaveFiltersEnabled] = useState(false);
|
|
const [isLoadingFilters, setIsLoadingFilters] = useState(true);
|
|
|
|
// ProxmoxVED / dev scripts visibility setting
|
|
const [showDevScripts, setShowDevScripts] = useState(() => {
|
|
if (typeof window === "undefined") return false;
|
|
return localStorage.getItem("showDevScripts") === "true";
|
|
});
|
|
|
|
useEffect(() => {
|
|
const onStorage = (e: StorageEvent) => {
|
|
if (e.key === "showDevScripts") {
|
|
setShowDevScripts(e.newValue === "true");
|
|
}
|
|
};
|
|
window.addEventListener("storage", onStorage);
|
|
return () => window.removeEventListener("storage", onStorage);
|
|
}, []);
|
|
const [isNewestMinimized, setIsNewestMinimized] = useState(false);
|
|
const downloadAbortRef = useRef(false);
|
|
const filtersInitRef = useRef(false);
|
|
const viewModeInitRef = useRef(false);
|
|
const gridRef = useRef<HTMLDivElement>(null);
|
|
|
|
const utils = api.useUtils();
|
|
|
|
const {
|
|
data: scriptCardsData,
|
|
isLoading: githubLoading,
|
|
error: githubError,
|
|
refetch,
|
|
} = api.scripts.getScriptCardsWithCategories.useQuery();
|
|
const {
|
|
data: localScriptsData,
|
|
isLoading: localLoading,
|
|
error: localError,
|
|
} = api.scripts.getAllDownloadedScripts.useQuery();
|
|
const { data: installedScriptsData } =
|
|
api.installedScripts.getAllInstalledScripts.useQuery();
|
|
const { open: openShell } = useShell();
|
|
const { data: scriptData } = api.scripts.getScriptBySlug.useQuery(
|
|
{ slug: selectedSlug ?? "" },
|
|
{ enabled: !!selectedSlug },
|
|
);
|
|
|
|
// Individual script download mutation
|
|
const loadSingleScriptMutation = api.scripts.loadScript.useMutation();
|
|
|
|
// Load SAVE_FILTER setting, saved filters, and view mode on component mount
|
|
useEffect(() => {
|
|
const loadSettings = async () => {
|
|
try {
|
|
// Load SAVE_FILTER setting
|
|
const saveFilterResponse = await fetch("/api/settings/save-filter");
|
|
let saveFilterEnabled = false;
|
|
if (saveFilterResponse.ok) {
|
|
const saveFilterData = await saveFilterResponse.json();
|
|
saveFilterEnabled = saveFilterData.enabled ?? false;
|
|
setSaveFiltersEnabled(saveFilterEnabled);
|
|
}
|
|
|
|
// Load saved filters if SAVE_FILTER is enabled
|
|
if (saveFilterEnabled) {
|
|
const filtersResponse = await fetch("/api/settings/filters");
|
|
if (filtersResponse.ok) {
|
|
const filtersData = (await filtersResponse.json()) as {
|
|
filters?: Partial<FilterState>;
|
|
};
|
|
if (filtersData.filters) {
|
|
setFilters(mergeFiltersWithDefaults(filtersData.filters));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Load view mode
|
|
const viewModeResponse = await fetch("/api/settings/view-mode");
|
|
if (viewModeResponse.ok) {
|
|
const viewModeData = await viewModeResponse.json();
|
|
const viewMode = viewModeData.viewMode;
|
|
if (
|
|
viewMode &&
|
|
typeof viewMode === "string" &&
|
|
(viewMode === "card" || viewMode === "list")
|
|
) {
|
|
setViewMode(viewMode);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("Error loading settings:", error);
|
|
} finally {
|
|
setIsLoadingFilters(false);
|
|
}
|
|
};
|
|
|
|
void loadSettings();
|
|
}, []);
|
|
|
|
// Save filters when they change (if SAVE_FILTER is enabled)
|
|
useEffect(() => {
|
|
if (!saveFiltersEnabled || isLoadingFilters) return;
|
|
// Skip the first fire after load — values haven't changed yet
|
|
if (!filtersInitRef.current) {
|
|
filtersInitRef.current = true;
|
|
return;
|
|
}
|
|
|
|
const saveFilters = async () => {
|
|
try {
|
|
await fetch("/api/settings/filters", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ filters }),
|
|
});
|
|
} catch (error) {
|
|
console.error("Error saving filters:", error);
|
|
}
|
|
};
|
|
|
|
// Debounce the save operation
|
|
const timeoutId = setTimeout(() => void saveFilters(), 500);
|
|
return () => clearTimeout(timeoutId);
|
|
}, [filters, saveFiltersEnabled, isLoadingFilters]);
|
|
|
|
// Save view mode when it changes
|
|
useEffect(() => {
|
|
if (isLoadingFilters) return;
|
|
// Skip the first fire after load — value hasn't changed yet
|
|
if (!viewModeInitRef.current) {
|
|
viewModeInitRef.current = true;
|
|
return;
|
|
}
|
|
|
|
const saveViewMode = async () => {
|
|
try {
|
|
await fetch("/api/settings/view-mode", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ viewMode }),
|
|
});
|
|
} catch (error) {
|
|
console.error("Error saving view mode:", error);
|
|
}
|
|
};
|
|
|
|
// Debounce the save operation
|
|
const timeoutId = setTimeout(() => void saveViewMode(), 300);
|
|
return () => clearTimeout(timeoutId);
|
|
}, [viewMode, isLoadingFilters]);
|
|
|
|
// Extract categories from metadata
|
|
const categories = React.useMemo((): string[] => {
|
|
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories)
|
|
return [];
|
|
|
|
return (scriptCardsData.metadata.categories as any[])
|
|
.filter((cat) => cat.id !== 0) // Exclude Miscellaneous for main list
|
|
.sort((a, b) => a.sort_order - b.sort_order)
|
|
.map((cat) => cat.name as string)
|
|
.filter((name): name is string => typeof name === "string");
|
|
}, [scriptCardsData]);
|
|
|
|
// Get GitHub scripts with download status (deduplicated)
|
|
const combinedScripts = React.useMemo((): ScriptCardType[] => {
|
|
if (!scriptCardsData?.success) return [];
|
|
|
|
// Use Map to deduplicate by slug/name
|
|
const scriptMap = new Map<string, ScriptCardType>();
|
|
|
|
scriptCardsData.cards?.forEach((script: ScriptCardType) => {
|
|
if (script?.name && script?.slug) {
|
|
// Use slug as unique identifier, only keep first occurrence
|
|
if (!scriptMap.has(script.slug)) {
|
|
scriptMap.set(script.slug, {
|
|
...script,
|
|
isDownloaded: false, // Will be updated by status check
|
|
isUpToDate: false, // Will be updated by status check
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
return Array.from(scriptMap.values());
|
|
}, [scriptCardsData]);
|
|
|
|
// Count scripts per category (using deduplicated scripts)
|
|
const categoryCounts = React.useMemo((): Record<string, number> => {
|
|
if (!scriptCardsData?.success) return {};
|
|
|
|
const counts: Record<string, number> = {};
|
|
|
|
// Initialize all categories with 0
|
|
categories.forEach((categoryName: string) => {
|
|
counts[categoryName] = 0;
|
|
});
|
|
|
|
// Count each unique script only once per category
|
|
combinedScripts.forEach((script) => {
|
|
if (script.categoryNames && script.slug) {
|
|
const countedCategories = new Set<string>();
|
|
script.categoryNames.forEach((categoryName: unknown) => {
|
|
if (
|
|
typeof categoryName === "string" &&
|
|
counts[categoryName] !== undefined &&
|
|
!countedCategories.has(categoryName)
|
|
) {
|
|
countedCategories.add(categoryName);
|
|
counts[categoryName]++;
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
return counts;
|
|
}, [categories, combinedScripts, scriptCardsData?.success]);
|
|
|
|
// Count dev scripts per category
|
|
const categoryDevCounts = React.useMemo((): Record<string, number> => {
|
|
if (!scriptCardsData?.success) return {};
|
|
|
|
const counts: Record<string, number> = {};
|
|
categories.forEach((name: string) => {
|
|
counts[name] = 0;
|
|
});
|
|
|
|
combinedScripts.forEach((script) => {
|
|
if (script.is_dev && script.categoryNames && script.slug) {
|
|
const counted = new Set<string>();
|
|
script.categoryNames.forEach((cat: unknown) => {
|
|
if (
|
|
typeof cat === "string" &&
|
|
counts[cat] !== undefined &&
|
|
!counted.has(cat)
|
|
) {
|
|
counted.add(cat);
|
|
counts[cat]++;
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
return counts;
|
|
}, [categories, combinedScripts, scriptCardsData?.success]);
|
|
|
|
// Update scripts with download status
|
|
const scriptsWithStatus = React.useMemo((): ScriptCardType[] => {
|
|
// Helper to normalize identifiers for robust matching
|
|
const normalizeId = (s?: string): string =>
|
|
(s ?? "")
|
|
.toLowerCase()
|
|
.replace(/\.(sh|bash|py|js|ts)$/g, "")
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "");
|
|
|
|
return combinedScripts.map((script) => {
|
|
if (!script?.name) {
|
|
return script; // Return as-is if invalid
|
|
}
|
|
|
|
// Check if there's a corresponding local script
|
|
const hasLocalVersion =
|
|
localScriptsData?.scripts?.some((local) => {
|
|
if (!local?.name) return false;
|
|
|
|
// Primary: Exact slug-to-slug matching (most reliable, prevents false positives)
|
|
if (local.slug && script.slug) {
|
|
if (local.slug.toLowerCase() === script.slug.toLowerCase()) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Secondary: Check install basenames (for edge cases where install script names differ from slugs)
|
|
// Only use normalized matching for install basenames, not for slug/name matching
|
|
const normalizedLocal = normalizeId(local.name);
|
|
const matchesInstallBasename =
|
|
(script as any)?.install_basenames?.some(
|
|
(base: string) => normalizeId(base) === normalizedLocal,
|
|
) ?? false;
|
|
return matchesInstallBasename;
|
|
}) ?? false;
|
|
|
|
return {
|
|
...script,
|
|
isDownloaded: hasLocalVersion,
|
|
// Removed isUpToDate - only show in modal for detailed comparison
|
|
};
|
|
});
|
|
}, [combinedScripts, localScriptsData]);
|
|
|
|
// Check if any filters are active (excluding default state)
|
|
const hasActiveFilters = React.useMemo(() => {
|
|
return (
|
|
filters.searchQuery?.trim() !== "" ||
|
|
filters.showUpdatable !== null ||
|
|
filters.selectedTypes.length > 0 ||
|
|
filters.selectedRepositories.length > 0 ||
|
|
filters.sortBy !== "name" ||
|
|
filters.sortOrder !== "asc" ||
|
|
(filters.quickFilter && filters.quickFilter !== "all") ||
|
|
filters.selectedCategory !== null
|
|
);
|
|
}, [filters]);
|
|
|
|
// Get the 6 newest scripts based on date_created field
|
|
const newestScripts = React.useMemo((): ScriptCardType[] => {
|
|
return scriptsWithStatus
|
|
.filter(
|
|
(script) => script?.date_created && (showDevScripts || !script?.is_dev),
|
|
) // Never show dev in newest unless ProxmoxVED enabled
|
|
.sort((a, b) => {
|
|
const aCreated = a?.date_created ?? "";
|
|
const bCreated = b?.date_created ?? "";
|
|
// Sort by date descending (newest first)
|
|
return bCreated.localeCompare(aCreated);
|
|
})
|
|
.slice(0, 6); // Take only the first 6
|
|
}, [scriptsWithStatus, showDevScripts]);
|
|
|
|
// Filter scripts based on all filters and category
|
|
const filteredScripts = React.useMemo((): ScriptCardType[] => {
|
|
let scripts = scriptsWithStatus;
|
|
|
|
// Hide dev scripts entirely when ProxmoxVED is disabled
|
|
if (!showDevScripts) {
|
|
scripts = scripts.filter((s) => !s?.is_dev);
|
|
}
|
|
|
|
// Filter by search query (use filters.searchQuery instead of deprecated searchQuery)
|
|
if (filters.searchQuery?.trim()) {
|
|
const query = filters.searchQuery.toLowerCase().trim();
|
|
|
|
if (query.length >= 1) {
|
|
scripts = scripts.filter((script) => {
|
|
if (!script || typeof script !== "object") {
|
|
return false;
|
|
}
|
|
|
|
const name = (script.name ?? "").toLowerCase();
|
|
const slug = (script.slug ?? "").toLowerCase();
|
|
|
|
return name.includes(query) ?? slug.includes(query);
|
|
});
|
|
}
|
|
}
|
|
|
|
// Filter by category using real category data from deduplicated scripts
|
|
if (filters.selectedCategory) {
|
|
scripts = scripts.filter((script) => {
|
|
if (!script) return false;
|
|
|
|
// Check if the deduplicated script has categoryNames that include the selected category
|
|
return (
|
|
script.categoryNames?.includes(filters.selectedCategory!) ?? false
|
|
);
|
|
});
|
|
}
|
|
|
|
// Filter by updateable status
|
|
if (filters.showUpdatable !== null) {
|
|
scripts = scripts.filter((script) => {
|
|
if (!script) return false;
|
|
const isUpdatable = script.updateable ?? false;
|
|
return filters.showUpdatable ? isUpdatable : !isUpdatable;
|
|
});
|
|
}
|
|
|
|
// Filter by script types
|
|
if (filters.selectedTypes.length > 0) {
|
|
scripts = scripts.filter((script) => {
|
|
if (!script) return false;
|
|
const scriptType = (script.type ?? "").toLowerCase();
|
|
return filters.selectedTypes.some((type) => {
|
|
const t = type.toLowerCase();
|
|
if (t === "ct") return scriptType === "ct" || scriptType === "lxc";
|
|
return scriptType === t;
|
|
});
|
|
});
|
|
}
|
|
|
|
// Apply quick filter
|
|
if (filters.quickFilter && filters.quickFilter !== "all") {
|
|
switch (filters.quickFilter) {
|
|
case "new":
|
|
scripts = scripts
|
|
.filter((s) => s?.date_created)
|
|
.sort((a, b) =>
|
|
(b?.date_created ?? "").localeCompare(a?.date_created ?? ""),
|
|
)
|
|
.slice(0, 15);
|
|
break;
|
|
case "updated":
|
|
scripts = scripts
|
|
.filter((s) => s?.date_updated)
|
|
.sort((a, b) =>
|
|
(b?.date_updated ?? "").localeCompare(a?.date_updated ?? ""),
|
|
);
|
|
break;
|
|
case "dev":
|
|
scripts = scripts.filter((s) => s?.is_dev === true);
|
|
break;
|
|
case "arm":
|
|
scripts = scripts.filter((s) => s?.has_arm === true);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Exclude newest scripts from main grid when no filters are active (they'll be shown in carousel)
|
|
if (!hasActiveFilters) {
|
|
const newestScriptSlugs = new Set(
|
|
newestScripts.map((script) => script.slug).filter(Boolean),
|
|
);
|
|
scripts = scripts.filter((script) => !newestScriptSlugs.has(script.slug));
|
|
}
|
|
|
|
// Apply sorting
|
|
scripts.sort((a, b) => {
|
|
if (!a || !b) return 0;
|
|
|
|
let compareValue = 0;
|
|
|
|
switch (filters.sortBy) {
|
|
case "name":
|
|
compareValue = (a.name ?? "").localeCompare(b.name ?? "");
|
|
break;
|
|
case "created":
|
|
// Get creation date from script metadata in JSON format (date_created: "YYYY-MM-DD")
|
|
const aCreated = a?.date_created ?? "";
|
|
const bCreated = b?.date_created ?? "";
|
|
|
|
// If both have dates, compare them directly
|
|
if (aCreated && bCreated) {
|
|
// For dates: asc = oldest first (2020 before 2024), desc = newest first (2024 before 2020)
|
|
compareValue = aCreated.localeCompare(bCreated);
|
|
} else if (aCreated && !bCreated) {
|
|
// Scripts with dates come before scripts without dates
|
|
compareValue = -1;
|
|
} else if (!aCreated && bCreated) {
|
|
// Scripts without dates come after scripts with dates
|
|
compareValue = 1;
|
|
} else {
|
|
// Both have no dates, fallback to name comparison
|
|
compareValue = (a.name ?? "").localeCompare(b.name ?? "");
|
|
}
|
|
break;
|
|
case "updated":
|
|
// Sort by date_created as proxy for "last updated"
|
|
const aUpdated = a?.date_created ?? "";
|
|
const bUpdated = b?.date_created ?? "";
|
|
if (aUpdated && bUpdated) {
|
|
compareValue = aUpdated.localeCompare(bUpdated);
|
|
} else if (aUpdated && !bUpdated) {
|
|
compareValue = -1;
|
|
} else if (!aUpdated && bUpdated) {
|
|
compareValue = 1;
|
|
} else {
|
|
compareValue = (a.name ?? "").localeCompare(b.name ?? "");
|
|
}
|
|
break;
|
|
default:
|
|
compareValue = (a.name ?? "").localeCompare(b.name ?? "");
|
|
}
|
|
|
|
// Apply sort order
|
|
return filters.sortOrder === "asc" ? compareValue : -compareValue;
|
|
});
|
|
|
|
return scripts;
|
|
}, [
|
|
scriptsWithStatus,
|
|
filters,
|
|
hasActiveFilters,
|
|
newestScripts,
|
|
showDevScripts,
|
|
]);
|
|
|
|
// Calculate filter counts for FilterBar
|
|
const filterCounts = React.useMemo(() => {
|
|
const installedCount = scriptsWithStatus.filter(
|
|
(script) => script?.isDownloaded,
|
|
).length;
|
|
const updatableCount = scriptsWithStatus.filter(
|
|
(script) => script?.updateable,
|
|
).length;
|
|
|
|
return { installedCount, updatableCount };
|
|
}, [scriptsWithStatus]);
|
|
|
|
// Sync legacy searchQuery with filters.searchQuery for backward compatibility
|
|
useEffect(() => {
|
|
if (searchQuery !== filters.searchQuery) {
|
|
setFilters((prev) => ({ ...prev, searchQuery }));
|
|
}
|
|
}, [searchQuery, filters.searchQuery]);
|
|
|
|
// Handle filter changes
|
|
const handleFiltersChange = (newFilters: FilterState) => {
|
|
setFilters(newFilters);
|
|
// Sync searchQuery for backward compatibility
|
|
setSearchQuery(newFilters.searchQuery);
|
|
};
|
|
|
|
// Selection management functions
|
|
const toggleScriptSelection = useCallback((slug: string) => {
|
|
setSelectedSlugs((prev) => {
|
|
const newSet = new Set(prev);
|
|
if (newSet.has(slug)) {
|
|
newSet.delete(slug);
|
|
} else {
|
|
newSet.add(slug);
|
|
}
|
|
return newSet;
|
|
});
|
|
}, []);
|
|
|
|
const selectAllVisible = () => {
|
|
const visibleSlugs = new Set(
|
|
filteredScripts.map((script) => script.slug).filter(Boolean),
|
|
);
|
|
setSelectedSlugs(visibleSlugs);
|
|
};
|
|
|
|
const clearSelection = () => {
|
|
setSelectedSlugs(new Set());
|
|
};
|
|
|
|
const getFriendlyErrorMessage = (error: string, slug: string): string => {
|
|
const errorLower = error.toLowerCase();
|
|
|
|
// Exact matches first (most specific)
|
|
if (error === "Script not found") {
|
|
return `Script "${slug}" is not available for download. It may not exist in the repository or has been removed.`;
|
|
}
|
|
|
|
if (error === "Failed to load script") {
|
|
return `Unable to download script "${slug}". Please check your internet connection and try again.`;
|
|
}
|
|
|
|
// Network/Connection errors
|
|
if (
|
|
errorLower.includes("network") ||
|
|
errorLower.includes("connection") ||
|
|
errorLower.includes("timeout")
|
|
) {
|
|
return "Network connection failed. Please check your internet connection and try again.";
|
|
}
|
|
|
|
// GitHub API errors
|
|
if (errorLower.includes("not found") || errorLower.includes("404")) {
|
|
return `Script "${slug}" not found in the repository. It may have been removed or renamed.`;
|
|
}
|
|
|
|
if (errorLower.includes("rate limit") || errorLower.includes("403")) {
|
|
return "GitHub API rate limit exceeded. Please wait a few minutes and try again.";
|
|
}
|
|
|
|
if (errorLower.includes("unauthorized") || errorLower.includes("401")) {
|
|
return "Access denied. The script may be private or require authentication.";
|
|
}
|
|
|
|
// File system errors
|
|
if (errorLower.includes("permission") || errorLower.includes("eacces")) {
|
|
return "Permission denied. Please check file system permissions.";
|
|
}
|
|
|
|
if (errorLower.includes("no space") || errorLower.includes("enospc")) {
|
|
return "Insufficient disk space. Please free up some space and try again.";
|
|
}
|
|
|
|
if (errorLower.includes("read-only") || errorLower.includes("erofs")) {
|
|
return "Cannot write to read-only file system. Please check your installation directory.";
|
|
}
|
|
|
|
// Script-specific errors
|
|
if (errorLower.includes("script not found")) {
|
|
return `Script "${slug}" not found in the local scripts directory.`;
|
|
}
|
|
|
|
if (
|
|
errorLower.includes("invalid script") ||
|
|
errorLower.includes("malformed")
|
|
) {
|
|
return `Script "${slug}" appears to be corrupted or invalid.`;
|
|
}
|
|
|
|
if (
|
|
errorLower.includes("already exists") ||
|
|
errorLower.includes("file exists")
|
|
) {
|
|
return `Script "${slug}" already exists locally. Skipping download.`;
|
|
}
|
|
|
|
// Generic fallbacks
|
|
if (errorLower.includes("timeout")) {
|
|
return "Download timed out. The script may be too large or the connection is slow.";
|
|
}
|
|
|
|
if (errorLower.includes("server error") || errorLower.includes("500")) {
|
|
return "Server error occurred. Please try again later.";
|
|
}
|
|
|
|
// If we can't categorize it, return a more helpful generic message
|
|
if (error.length > 100) {
|
|
return `Download failed: ${error.substring(0, 100)}...`;
|
|
}
|
|
|
|
return `Download failed: ${error}`;
|
|
};
|
|
|
|
const downloadScriptsIndividually = async (slugsToDownload: string[]) => {
|
|
downloadAbortRef.current = false;
|
|
setDownloadProgress({
|
|
current: 0,
|
|
total: slugsToDownload.length,
|
|
currentScript: "",
|
|
failed: [],
|
|
});
|
|
|
|
const successful: Array<{ slug: string; files: string[] }> = [];
|
|
const failed: Array<{ slug: string; error: string }> = [];
|
|
|
|
for (let i = 0; i < slugsToDownload.length; i++) {
|
|
if (downloadAbortRef.current) break;
|
|
const slug = slugsToDownload[i];
|
|
|
|
// Update progress with current script
|
|
setDownloadProgress((prev) =>
|
|
prev
|
|
? {
|
|
...prev,
|
|
current: i,
|
|
currentScript: slug ?? "",
|
|
}
|
|
: null,
|
|
);
|
|
|
|
try {
|
|
// Download individual script
|
|
const result = await loadSingleScriptMutation.mutateAsync({
|
|
slug: slug ?? "",
|
|
});
|
|
|
|
if (result.success) {
|
|
successful.push({ slug: slug ?? "", files: result.files ?? [] });
|
|
} else {
|
|
const error =
|
|
"error" in result ? result.error : "Failed to load script";
|
|
const userFriendlyError = getFriendlyErrorMessage(
|
|
error ?? "Unknown error",
|
|
slug ?? "",
|
|
);
|
|
failed.push({ slug: slug ?? "", error: userFriendlyError });
|
|
}
|
|
} catch (error) {
|
|
const errorMessage =
|
|
error instanceof Error ? error.message : "Failed to load script";
|
|
const userFriendlyError = getFriendlyErrorMessage(
|
|
errorMessage,
|
|
slug ?? "",
|
|
);
|
|
failed.push({
|
|
slug: slug ?? "",
|
|
error: userFriendlyError,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Final progress update
|
|
setDownloadProgress((prev) =>
|
|
prev
|
|
? {
|
|
...prev,
|
|
current: slugsToDownload.length,
|
|
failed,
|
|
}
|
|
: null,
|
|
);
|
|
|
|
// Clear selection and refetch to update card download status
|
|
setSelectedSlugs(new Set());
|
|
void refetch();
|
|
void utils.scripts.getAllDownloadedScripts.invalidate();
|
|
|
|
// Keep progress bar visible until user navigates away or manually dismisses
|
|
// Progress bar will stay visible to show final results
|
|
};
|
|
|
|
const handleBatchDownload = () => {
|
|
const slugsToDownload = Array.from(selectedSlugs);
|
|
if (slugsToDownload.length > 0) {
|
|
void downloadScriptsIndividually(slugsToDownload);
|
|
}
|
|
};
|
|
|
|
const handleDownloadAllFiltered = () => {
|
|
let scriptsToDownload: ScriptCardType[] = filteredScripts;
|
|
|
|
if (!hasActiveFilters) {
|
|
const scriptMap = new Map<string, ScriptCardType>();
|
|
|
|
filteredScripts.forEach((script) => {
|
|
if (script?.slug) {
|
|
scriptMap.set(script.slug, script);
|
|
}
|
|
});
|
|
|
|
newestScripts.forEach((script) => {
|
|
if (script?.slug && !scriptMap.has(script.slug)) {
|
|
scriptMap.set(script.slug, script);
|
|
}
|
|
});
|
|
|
|
scriptsToDownload = Array.from(scriptMap.values());
|
|
}
|
|
|
|
const slugsToDownload = scriptsToDownload
|
|
.map((script) => script.slug)
|
|
.filter(Boolean);
|
|
if (slugsToDownload.length > 0) {
|
|
void downloadScriptsIndividually(slugsToDownload);
|
|
}
|
|
};
|
|
|
|
// Handle category selection with auto-scroll
|
|
const handleCategorySelect = (category: string | null) => {
|
|
handleFiltersChange({ ...filters, selectedCategory: category });
|
|
};
|
|
|
|
// Auto-scroll effect when category changes
|
|
useEffect(() => {
|
|
if (filters.selectedCategory && gridRef.current) {
|
|
const timeoutId = setTimeout(() => {
|
|
gridRef.current?.scrollIntoView({
|
|
behavior: "smooth",
|
|
block: "start",
|
|
inline: "nearest",
|
|
});
|
|
}, 100);
|
|
|
|
return () => clearTimeout(timeoutId);
|
|
}
|
|
}, [filters.selectedCategory]);
|
|
|
|
// Clear selection when switching between card/list views
|
|
useEffect(() => {
|
|
setSelectedSlugs(new Set());
|
|
}, [viewMode]);
|
|
|
|
// Clear progress bar when component unmounts
|
|
useEffect(() => {
|
|
return () => {
|
|
setDownloadProgress(null);
|
|
};
|
|
}, []);
|
|
|
|
const handleCardClick = useCallback((scriptCard: ScriptCardType) => {
|
|
// All scripts are GitHub scripts, open modal
|
|
setSelectedSlug(scriptCard.slug);
|
|
setIsModalOpen(true);
|
|
}, []);
|
|
|
|
const normalizeSlug = (s?: string): string =>
|
|
(s ?? "")
|
|
.toLowerCase()
|
|
.replace(/\.(sh|bash|py|js|ts)$/i, "")
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "");
|
|
|
|
// Map normalized slug → first installed container with a container_id
|
|
const installedContainerMap = React.useMemo((): Map<string, any> => {
|
|
const map = new Map<string, any>();
|
|
const scripts = (installedScriptsData as any)?.scripts ?? [];
|
|
for (const s of scripts) {
|
|
if (!s.container_id || s.status === "failed") continue;
|
|
const key = normalizeSlug(s.script_name);
|
|
if (!map.has(key)) map.set(key, s);
|
|
}
|
|
return map;
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [installedScriptsData]);
|
|
|
|
const handleShellClick = useCallback(
|
|
(script: ScriptCardType) => {
|
|
const container = installedContainerMap.get(normalizeSlug(script.slug));
|
|
if (!container) return;
|
|
const server =
|
|
container.server_id && container.server_user
|
|
? {
|
|
id: container.server_id,
|
|
name: container.server_name ?? "",
|
|
ip: container.server_ip ?? "",
|
|
user: container.server_user,
|
|
password: container.server_password ?? undefined,
|
|
auth_type: (container.server_auth_type ?? "password") as any,
|
|
ssh_key: container.server_ssh_key ?? undefined,
|
|
ssh_key_passphrase:
|
|
container.server_ssh_key_passphrase ?? undefined,
|
|
ssh_port: container.server_ssh_port ?? 22,
|
|
created_at: null,
|
|
updated_at: null,
|
|
}
|
|
: undefined;
|
|
openShell({
|
|
containerId: container.container_id,
|
|
containerName: container.script_name,
|
|
server,
|
|
containerType: container.is_vm ? "vm" : "lxc",
|
|
});
|
|
},
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[installedContainerMap, openShell],
|
|
);
|
|
|
|
const handleCloseModal = useCallback(() => {
|
|
setIsModalOpen(false);
|
|
setSelectedSlug(null);
|
|
}, []);
|
|
|
|
if (githubLoading || localLoading) {
|
|
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>
|
|
<span className="text-muted-foreground ml-2">Loading scripts...</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (githubError || localError) {
|
|
return (
|
|
<div className="py-12 text-center">
|
|
<div className="text-error mb-4">
|
|
<svg
|
|
className="mx-auto mb-2 h-12 w-12"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
|
|
/>
|
|
</svg>
|
|
<p className="text-lg font-medium">Failed to load scripts</p>
|
|
<p className="text-muted-foreground mt-1 text-sm">
|
|
{githubError?.message ??
|
|
localError?.message ??
|
|
"Unknown error occurred"}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
onClick={() => refetch()}
|
|
variant="default"
|
|
size="default"
|
|
className="mt-4"
|
|
>
|
|
Try Again
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!scriptsWithStatus?.length) {
|
|
return (
|
|
<div className="py-12 text-center">
|
|
<div className="text-muted-foreground">
|
|
<svg
|
|
className="mx-auto mb-4 h-12 w-12"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
|
/>
|
|
</svg>
|
|
<p className="text-lg font-medium">No scripts found</p>
|
|
<p className="text-muted-foreground mt-1 text-sm">
|
|
No script files were found in the repository or local directory.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4 lg:flex-row lg:gap-6">
|
|
{/* Category Sidebar */}
|
|
<div className="order-2 flex-shrink-0 lg:order-1">
|
|
<CategorySidebar
|
|
categories={categories}
|
|
categoryCounts={categoryCounts}
|
|
categoryDevCounts={categoryDevCounts}
|
|
totalScripts={scriptsWithStatus.length}
|
|
selectedCategory={filters.selectedCategory}
|
|
onCategorySelect={handleCategorySelect}
|
|
showDevScripts={showDevScripts}
|
|
/>
|
|
</div>
|
|
|
|
{/* Main Content */}
|
|
<div className="order-1 min-w-0 flex-1 lg:order-2" ref={gridRef}>
|
|
{/* Enhanced Filter Bar */}
|
|
<FilterBar
|
|
filters={filters}
|
|
onFiltersChange={handleFiltersChange}
|
|
totalScripts={scriptsWithStatus.length}
|
|
filteredCount={filteredScripts.length}
|
|
updatableCount={filterCounts.updatableCount}
|
|
saveFiltersEnabled={saveFiltersEnabled}
|
|
isLoadingFilters={isLoadingFilters}
|
|
categories={categories}
|
|
categoryCounts={categoryCounts}
|
|
showDevScripts={showDevScripts}
|
|
/>
|
|
|
|
{/* View Toggle */}
|
|
<ViewToggle viewMode={viewMode} onViewModeChange={setViewMode} />
|
|
|
|
{/* Newest Scripts Carousel - Only show when no search, filters, or category is active */}
|
|
{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">
|
|
<h2 className="text-foreground flex items-center gap-2 text-xl font-semibold">
|
|
<Clock className="text-primary h-6 w-6" />
|
|
Newest Scripts
|
|
</h2>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-muted-foreground text-sm">
|
|
{newestScripts.length} recently added
|
|
</span>
|
|
<Button
|
|
onClick={() => setIsNewestMinimized(!isNewestMinimized)}
|
|
variant="ghost"
|
|
size="icon"
|
|
className="text-muted-foreground hover:text-foreground h-8 w-8"
|
|
title={
|
|
isNewestMinimized
|
|
? "Expand newest scripts"
|
|
: "Minimize newest scripts"
|
|
}
|
|
>
|
|
<svg
|
|
className={`h-4 w-4 transition-transform ${isNewestMinimized ? "" : "rotate-180"}`}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M5 15l7-7 7 7"
|
|
/>
|
|
</svg>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{!isNewestMinimized && (
|
|
<div className="scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-600 scrollbar-track-transparent overflow-x-auto">
|
|
<div
|
|
className="flex gap-4 pb-2"
|
|
style={{ minWidth: "max-content" }}
|
|
>
|
|
{newestScripts.map((script, index) => {
|
|
if (!script || typeof script !== "object") {
|
|
return null;
|
|
}
|
|
|
|
const uniqueKey = `newest-${script.slug ?? "unknown"}-${script.name ?? "unnamed"}-${index}`;
|
|
|
|
return (
|
|
<div
|
|
key={uniqueKey}
|
|
className="w-64 flex-shrink-0 sm:w-72 md:w-80"
|
|
>
|
|
<div className="relative">
|
|
<ScriptCard
|
|
script={script}
|
|
onClick={handleCardClick}
|
|
isSelected={selectedSlugs.has(script.slug ?? "")}
|
|
onToggleSelect={toggleScriptSelection}
|
|
onShell={
|
|
installedContainerMap.has(
|
|
normalizeSlug(script.slug),
|
|
)
|
|
? () => handleShellClick(script)
|
|
: undefined
|
|
}
|
|
/>
|
|
{/* NEW badge */}
|
|
<div className="bg-success text-success-foreground absolute top-2 right-2 z-10 rounded-md px-2 py-1 text-xs font-semibold shadow-md">
|
|
NEW
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Action Buttons */}
|
|
<div className="mb-4 flex flex-wrap gap-2">
|
|
{selectedSlugs.size > 0 ? (
|
|
<Button
|
|
onClick={handleBatchDownload}
|
|
disabled={loadSingleScriptMutation.isPending}
|
|
variant="outline"
|
|
size="sm"
|
|
className="bg-info/10 hover:bg-info/20 border-info/30 text-info hover:text-info-foreground hover:border-info/50"
|
|
>
|
|
{loadSingleScriptMutation.isPending ? (
|
|
<>
|
|
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-b-2 border-current"></div>
|
|
Downloading...
|
|
</>
|
|
) : (
|
|
`Download Selected (${selectedSlugs.size})`
|
|
)}
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
onClick={handleDownloadAllFiltered}
|
|
disabled={
|
|
filteredScripts.length === 0 ||
|
|
loadSingleScriptMutation.isPending
|
|
}
|
|
variant="outline"
|
|
size="sm"
|
|
>
|
|
{loadSingleScriptMutation.isPending ? (
|
|
<>
|
|
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-b-2 border-current"></div>
|
|
Downloading...
|
|
</>
|
|
) : (
|
|
`Download All Filtered (${filteredScripts.length})`
|
|
)}
|
|
</Button>
|
|
)}
|
|
|
|
{selectedSlugs.size > 0 && (
|
|
<Button onClick={clearSelection} variant="outline" size="default">
|
|
Clear Selection
|
|
</Button>
|
|
)}
|
|
|
|
{filteredScripts.length > 0 && (
|
|
<Button onClick={selectAllVisible} variant="outline" size="default">
|
|
Select All Visible
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Progress Bar */}
|
|
{downloadProgress && (
|
|
<div className="glass-card-static mb-4 border p-4">
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<div className="flex flex-col">
|
|
<span className="text-foreground text-sm font-medium">
|
|
{downloadProgress.current >= downloadProgress.total
|
|
? "Download completed"
|
|
: "Downloading scripts"}
|
|
... {downloadProgress.current} of {downloadProgress.total}
|
|
</span>
|
|
{downloadProgress.currentScript &&
|
|
downloadProgress.current < downloadProgress.total && (
|
|
<span className="text-muted-foreground text-xs">
|
|
Currently downloading: {downloadProgress.currentScript}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-muted-foreground text-sm">
|
|
{Math.round(
|
|
(downloadProgress.current / downloadProgress.total) * 100,
|
|
)}
|
|
%
|
|
</span>
|
|
{downloadProgress.current < downloadProgress.total && (
|
|
<button
|
|
onClick={() => {
|
|
downloadAbortRef.current = true;
|
|
}}
|
|
className="text-destructive hover:text-destructive/80 text-xs font-medium transition-colors"
|
|
title="Cancel download"
|
|
>
|
|
Cancel
|
|
</button>
|
|
)}
|
|
{downloadProgress.current >= downloadProgress.total && (
|
|
<button
|
|
onClick={() => setDownloadProgress(null)}
|
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
|
title="Dismiss progress bar"
|
|
>
|
|
<svg
|
|
className="h-4 w-4"
|
|
fill="currentColor"
|
|
viewBox="0 0 20 20"
|
|
>
|
|
<path
|
|
fillRule="evenodd"
|
|
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
|
clipRule="evenodd"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Progress Bar */}
|
|
<div className="bg-muted mb-2 h-2 w-full rounded-full">
|
|
<div
|
|
className={`h-2 rounded-full transition-all duration-300 ease-out ${
|
|
downloadProgress.failed.length > 0
|
|
? "bg-warning"
|
|
: "bg-primary"
|
|
}`}
|
|
style={{
|
|
width: `${(downloadProgress.current / downloadProgress.total) * 100}%`,
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Progress Visualization */}
|
|
<div className="text-muted-foreground mb-2 flex items-center text-xs">
|
|
<span className="mr-2">Progress:</span>
|
|
<div className="flex flex-wrap gap-1">
|
|
{Array.from({ length: downloadProgress.total }, (_, i) => {
|
|
const isCompleted = i < downloadProgress.current;
|
|
const isCurrent = i === downloadProgress.current;
|
|
const isFailed = downloadProgress.failed.some(
|
|
(f) => f.slug === downloadProgress.currentScript,
|
|
);
|
|
|
|
return (
|
|
<span
|
|
key={i}
|
|
className={`rounded px-1 py-0.5 text-xs ${
|
|
isCompleted
|
|
? isFailed
|
|
? "bg-error/10 text-error"
|
|
: "bg-success/10 text-success"
|
|
: isCurrent
|
|
? "bg-info/10 text-info animate-pulse"
|
|
: "bg-muted text-muted-foreground"
|
|
}`}
|
|
>
|
|
{isCompleted
|
|
? isFailed
|
|
? "✗"
|
|
: "✓"
|
|
: isCurrent
|
|
? "⟳"
|
|
: "○"}
|
|
</span>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Failed Scripts Details */}
|
|
{downloadProgress.failed.length > 0 && (
|
|
<div className="bg-error/10 border-error/20 mt-3 rounded-lg border p-3">
|
|
<div className="mb-2 flex items-center">
|
|
<svg
|
|
className="text-error mr-2 h-4 w-4"
|
|
fill="currentColor"
|
|
viewBox="0 0 20 20"
|
|
>
|
|
<path
|
|
fillRule="evenodd"
|
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
|
clipRule="evenodd"
|
|
/>
|
|
</svg>
|
|
<span className="text-error-foreground text-sm font-medium">
|
|
Failed Downloads ({downloadProgress.failed.length})
|
|
</span>
|
|
</div>
|
|
<div className="space-y-1">
|
|
{downloadProgress.failed.map((failed, index) => (
|
|
<div key={index} className="text-error/80 text-xs">
|
|
<span className="font-medium">{failed.slug}:</span>{" "}
|
|
{failed.error}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Legacy Search Bar (keeping for backward compatibility, but hidden) */}
|
|
<div className="mb-8 hidden">
|
|
<div className="relative mx-auto max-w-md">
|
|
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
|
<svg
|
|
className="text-muted-foreground h-5 w-5"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<input
|
|
type="text"
|
|
placeholder="Search scripts by name..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="border-border bg-card placeholder-muted-foreground text-foreground focus:placeholder-muted-foreground focus:ring-ring focus:border-ring block w-full rounded-lg border py-3 pr-3 pl-10 text-sm leading-5 focus:ring-2 focus:outline-none"
|
|
/>
|
|
{searchQuery && (
|
|
<button
|
|
onClick={() => setSearchQuery("")}
|
|
className="text-muted-foreground hover:text-foreground absolute inset-y-0 right-0 flex items-center pr-3"
|
|
>
|
|
<svg
|
|
className="h-5 w-5"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M6 18L18 6M6 6l12 12"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
{(searchQuery || filters.selectedCategory) && (
|
|
<div className="text-muted-foreground mt-2 text-center text-sm">
|
|
{filteredScripts.length === 0 ? (
|
|
<span>
|
|
No scripts found
|
|
{searchQuery ? ` matching "${searchQuery}"` : ""}
|
|
{filters.selectedCategory
|
|
? ` in category "${filters.selectedCategory}"`
|
|
: ""}
|
|
</span>
|
|
) : (
|
|
<span>
|
|
Found {filteredScripts.length} script
|
|
{filteredScripts.length !== 1 ? "s" : ""}
|
|
{searchQuery ? ` matching "${searchQuery}"` : ""}
|
|
{filters.selectedCategory
|
|
? ` in category "${filters.selectedCategory}"`
|
|
: ""}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Scripts Grid */}
|
|
{filteredScripts.length === 0 &&
|
|
(filters.searchQuery ||
|
|
filters.selectedCategory ||
|
|
filters.showUpdatable !== null ||
|
|
filters.selectedTypes.length > 0) ? (
|
|
<div className="py-12 text-center">
|
|
<div className="text-muted-foreground">
|
|
<svg
|
|
className="mx-auto mb-4 h-12 w-12"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
|
/>
|
|
</svg>
|
|
<p className="text-lg font-medium">No matching scripts found</p>
|
|
<p className="text-muted-foreground mt-1 text-sm">
|
|
Try different filter settings or clear all filters.
|
|
</p>
|
|
<div className="mt-4 flex justify-center gap-2">
|
|
{filters.searchQuery && (
|
|
<Button
|
|
onClick={() =>
|
|
handleFiltersChange({ ...filters, searchQuery: "" })
|
|
}
|
|
variant="default"
|
|
size="default"
|
|
>
|
|
Clear Search
|
|
</Button>
|
|
)}
|
|
{filters.selectedCategory && (
|
|
<Button
|
|
onClick={() => handleCategorySelect(null)}
|
|
variant="secondary"
|
|
size="default"
|
|
>
|
|
Clear Category
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : viewMode === "card" ? (
|
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
|
{filteredScripts.map((script, index) => {
|
|
// Add validation to ensure script has required properties
|
|
if (!script || typeof script !== "object") {
|
|
return null;
|
|
}
|
|
|
|
// Create a unique key by combining slug, name, and index to handle duplicates
|
|
const uniqueKey = `${script.slug ?? "unknown"}-${script.name ?? "unnamed"}-${index}`;
|
|
|
|
return (
|
|
<ScriptCard
|
|
key={uniqueKey}
|
|
script={script}
|
|
onClick={handleCardClick}
|
|
isSelected={selectedSlugs.has(script.slug ?? "")}
|
|
onToggleSelect={toggleScriptSelection}
|
|
onShell={
|
|
installedContainerMap.has(normalizeSlug(script.slug))
|
|
? () => handleShellClick(script)
|
|
: undefined
|
|
}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{filteredScripts.map((script, index) => {
|
|
// Add validation to ensure script has required properties
|
|
if (!script || typeof script !== "object") {
|
|
return null;
|
|
}
|
|
|
|
// Create a unique key by combining slug, name, and index to handle duplicates
|
|
const uniqueKey = `${script.slug ?? "unknown"}-${script.name ?? "unnamed"}-${index}`;
|
|
|
|
return (
|
|
<ScriptCardList
|
|
key={uniqueKey}
|
|
script={script}
|
|
onClick={handleCardClick}
|
|
isSelected={selectedSlugs.has(script.slug ?? "")}
|
|
onToggleSelect={toggleScriptSelection}
|
|
onShell={
|
|
installedContainerMap.has(normalizeSlug(script.slug))
|
|
? () => handleShellClick(script)
|
|
: undefined
|
|
}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
<ScriptDetailModal
|
|
script={scriptData?.success ? scriptData.script : null}
|
|
isOpen={isModalOpen}
|
|
onClose={handleCloseModal}
|
|
orderedSlugs={[
|
|
...(!hasActiveFilters ? newestScripts.map((s) => s.slug) : []),
|
|
...filteredScripts.map((s) => s.slug),
|
|
]}
|
|
onSelectSlug={(slug) => setSelectedSlug(slug)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|