725 lines
24 KiB
TypeScript
725 lines
24 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useRef, useEffect } 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 { ConfirmationModal } from "./ConfirmationModal";
|
|
import { Button } from "./ui/button";
|
|
import { RefreshCw } from "lucide-react";
|
|
import type { ScriptCard as ScriptCardType } from "~/types/script";
|
|
import type { Server } from "~/types/server";
|
|
import { getDefaultFilters, mergeFiltersWithDefaults } from "./filterUtils";
|
|
|
|
interface DownloadedScriptsTabProps {
|
|
onInstallScript?: (
|
|
scriptPath: string,
|
|
scriptName: string,
|
|
mode?: "local" | "ssh",
|
|
server?: Server,
|
|
) => void;
|
|
}
|
|
|
|
export function DownloadedScriptsTab({
|
|
onInstallScript,
|
|
}: DownloadedScriptsTabProps) {
|
|
const [selectedSlug, setSelectedSlug] = useState<string | null>(null);
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
|
const [viewMode, setViewMode] = useState<"card" | "list">("card");
|
|
const [filters, setFilters] = useState<FilterState>(getDefaultFilters());
|
|
const [saveFiltersEnabled, setSaveFiltersEnabled] = useState(false);
|
|
const [isLoadingFilters, setIsLoadingFilters] = useState(true);
|
|
const [updateAllConfirmOpen, setUpdateAllConfirmOpen] = useState(false);
|
|
const [updateResult, setUpdateResult] = useState<{
|
|
successCount: number;
|
|
failCount: number;
|
|
failed: { slug: string; error: string }[];
|
|
} | null>(null);
|
|
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: scriptData } = api.scripts.getScriptBySlug.useQuery(
|
|
{ slug: selectedSlug ?? "" },
|
|
{ enabled: !!selectedSlug },
|
|
);
|
|
|
|
const loadMultipleScriptsMutation = api.scripts.loadMultipleScripts.useMutation({
|
|
onSuccess: (data) => {
|
|
void utils.scripts.getAllDownloadedScripts.invalidate();
|
|
void utils.scripts.getScriptCardsWithCategories.invalidate();
|
|
setUpdateResult({
|
|
successCount: data.successful?.length ?? 0,
|
|
failCount: data.failed?.length ?? 0,
|
|
failed: (data.failed ?? []).map((f) => ({
|
|
slug: f.slug,
|
|
error: f.error ?? "Unknown error",
|
|
})),
|
|
});
|
|
setTimeout(() => setUpdateResult(null), 8000);
|
|
},
|
|
onError: (error) => {
|
|
setUpdateResult({
|
|
successCount: 0,
|
|
failCount: 1,
|
|
failed: [{ slug: "Request failed", error: error.message }],
|
|
});
|
|
setTimeout(() => setUpdateResult(null), 8000);
|
|
},
|
|
});
|
|
|
|
// 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;
|
|
|
|
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;
|
|
|
|
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,
|
|
source: "github" as const,
|
|
isDownloaded: false, // Will be updated by status check
|
|
isUpToDate: false, // Will be updated by status check
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
return Array.from(scriptMap.values());
|
|
}, [scriptCardsData]);
|
|
|
|
// Update scripts with download status and filter to only downloaded scripts
|
|
const downloadedScripts = React.useMemo((): ScriptCardType[] => {
|
|
// Helper to normalize identifiers so underscores vs hyphens don't break matches
|
|
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,
|
|
};
|
|
})
|
|
.filter((script) => script.isDownloaded); // Only show downloaded scripts
|
|
}, [combinedScripts, localScriptsData]);
|
|
|
|
// Count scripts per category (using downloaded scripts only)
|
|
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 downloaded script only once per category
|
|
downloadedScripts.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, downloadedScripts, scriptCardsData?.success]);
|
|
|
|
// Filter scripts based on all filters and category
|
|
const filteredScripts = React.useMemo((): ScriptCardType[] => {
|
|
let scripts = downloadedScripts;
|
|
|
|
// Filter by search query
|
|
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 downloaded scripts
|
|
if (selectedCategory) {
|
|
scripts = scripts.filter((script) => {
|
|
if (!script) return false;
|
|
|
|
// Check if the downloaded script has categoryNames that include the selected category
|
|
return script.categoryNames?.includes(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();
|
|
|
|
// Map non-standard types to standard categories
|
|
const mappedType = scriptType === "turnkey" ? "ct" : scriptType;
|
|
|
|
return filters.selectedTypes.some(
|
|
(type) => type.toLowerCase() === mappedType,
|
|
);
|
|
});
|
|
}
|
|
|
|
// Filter by repositories
|
|
if (filters.selectedRepositories.length > 0) {
|
|
scripts = scripts.filter((script) => {
|
|
if (!script) return false;
|
|
const repoUrl = script.repository_url;
|
|
|
|
// If script has no repository_url, exclude it when filtering by repositories
|
|
if (!repoUrl) {
|
|
return false;
|
|
}
|
|
|
|
// Only include scripts from selected repositories
|
|
return filters.selectedRepositories.includes(repoUrl);
|
|
});
|
|
}
|
|
|
|
// 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;
|
|
default:
|
|
compareValue = (a.name ?? "").localeCompare(b.name ?? "");
|
|
}
|
|
|
|
// Apply sort order
|
|
return filters.sortOrder === "asc" ? compareValue : -compareValue;
|
|
});
|
|
|
|
return scripts;
|
|
}, [downloadedScripts, filters, selectedCategory]);
|
|
|
|
// Calculate filter counts for FilterBar
|
|
const filterCounts = React.useMemo(() => {
|
|
const updatableCount = downloadedScripts.filter(
|
|
(script) => script?.updateable,
|
|
).length;
|
|
|
|
return { installedCount: downloadedScripts.length, updatableCount };
|
|
}, [downloadedScripts]);
|
|
|
|
// Handle filter changes
|
|
const handleFiltersChange = (newFilters: FilterState) => {
|
|
setFilters(newFilters);
|
|
};
|
|
|
|
// Handle category selection with auto-scroll
|
|
const handleCategorySelect = (category: string | null) => {
|
|
setSelectedCategory(category);
|
|
};
|
|
|
|
// Auto-scroll effect when category changes
|
|
useEffect(() => {
|
|
if (selectedCategory && gridRef.current) {
|
|
const timeoutId = setTimeout(() => {
|
|
gridRef.current?.scrollIntoView({
|
|
behavior: "smooth",
|
|
block: "start",
|
|
inline: "nearest",
|
|
});
|
|
}, 100);
|
|
|
|
return () => clearTimeout(timeoutId);
|
|
}
|
|
}, [selectedCategory]);
|
|
|
|
const handleCardClick = (scriptCard: ScriptCardType) => {
|
|
// All scripts are GitHub scripts, open modal
|
|
setSelectedSlug(scriptCard.slug);
|
|
setIsModalOpen(true);
|
|
};
|
|
|
|
const handleCloseModal = () => {
|
|
setIsModalOpen(false);
|
|
setSelectedSlug(null);
|
|
};
|
|
|
|
const handleUpdateAllClick = () => {
|
|
setUpdateResult(null);
|
|
setUpdateAllConfirmOpen(true);
|
|
};
|
|
|
|
const handleUpdateAllConfirm = () => {
|
|
setUpdateAllConfirmOpen(false);
|
|
const slugs = downloadedScripts
|
|
.map((s) => s.slug)
|
|
.filter((slug): slug is string => Boolean(slug));
|
|
if (slugs.length > 0) {
|
|
loadMultipleScriptsMutation.mutate({ slugs });
|
|
}
|
|
};
|
|
|
|
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 downloaded 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 downloaded 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 (!downloadedScripts?.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 downloaded scripts found</p>
|
|
<p className="text-muted-foreground mt-1 text-sm">
|
|
You haven't downloaded any scripts yet. Visit the Available
|
|
Scripts tab to download some scripts.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<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}
|
|
totalScripts={downloadedScripts.length}
|
|
selectedCategory={selectedCategory}
|
|
onCategorySelect={handleCategorySelect}
|
|
/>
|
|
</div>
|
|
|
|
{/* Main Content */}
|
|
<div className="order-1 min-w-0 flex-1 lg:order-2" ref={gridRef}>
|
|
{/* Update all downloaded scripts */}
|
|
<div className="mb-4 flex flex-wrap items-center gap-3">
|
|
<Button
|
|
onClick={handleUpdateAllClick}
|
|
disabled={loadMultipleScriptsMutation.isPending}
|
|
variant="secondary"
|
|
size="default"
|
|
className="flex items-center gap-2"
|
|
>
|
|
{loadMultipleScriptsMutation.isPending ? (
|
|
<>
|
|
<RefreshCw className="h-4 w-4 animate-spin" />
|
|
<span>Updating...</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<RefreshCw className="h-4 w-4" />
|
|
<span>Update all downloaded scripts</span>
|
|
</>
|
|
)}
|
|
</Button>
|
|
{updateResult && (
|
|
<span className="text-muted-foreground text-sm">
|
|
Updated {updateResult.successCount} successfully
|
|
{updateResult.failCount > 0
|
|
? `, ${updateResult.failCount} failed`
|
|
: ""}
|
|
.
|
|
{updateResult.failCount > 0 && updateResult.failed.length > 0 && (
|
|
<span className="ml-1" title={updateResult.failed.map((f) => `${f.slug}: ${f.error}`).join("\n")}>
|
|
(hover for details)
|
|
</span>
|
|
)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Enhanced Filter Bar */}
|
|
<FilterBar
|
|
filters={filters}
|
|
onFiltersChange={handleFiltersChange}
|
|
totalScripts={downloadedScripts.length}
|
|
filteredCount={filteredScripts.length}
|
|
updatableCount={filterCounts.updatableCount}
|
|
saveFiltersEnabled={saveFiltersEnabled}
|
|
isLoadingFilters={isLoadingFilters}
|
|
/>
|
|
|
|
{/* View Toggle */}
|
|
<ViewToggle viewMode={viewMode} onViewModeChange={setViewMode} />
|
|
|
|
{/* Scripts Grid */}
|
|
{filteredScripts.length === 0 &&
|
|
(filters.searchQuery ||
|
|
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 downloaded 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>
|
|
)}
|
|
{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}
|
|
/>
|
|
);
|
|
})}
|
|
</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}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
<ScriptDetailModal
|
|
script={scriptData?.success ? scriptData.script : null}
|
|
isOpen={isModalOpen}
|
|
onClose={handleCloseModal}
|
|
onInstallScript={onInstallScript}
|
|
/>
|
|
|
|
<ConfirmationModal
|
|
isOpen={updateAllConfirmOpen}
|
|
onClose={() => setUpdateAllConfirmOpen(false)}
|
|
onConfirm={handleUpdateAllConfirm}
|
|
title="Update all downloaded scripts"
|
|
message={`Update all ${downloadedScripts.length} downloaded scripts? This may take several minutes.`}
|
|
variant="simple"
|
|
confirmButtonText="Update all"
|
|
cancelButtonText="Cancel"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|