"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(null); const [isModalOpen, setIsModalOpen] = useState(false); const [selectedCategory, setSelectedCategory] = useState(null); const [viewMode, setViewMode] = useState<"card" | "list">("card"); const [filters, setFilters] = useState(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(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; }; 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(); 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 => { if (!scriptCardsData?.success) return {}; const counts: Record = {}; // 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(); 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 (
Loading downloaded scripts...
); } if (githubError || localError) { return (

Failed to load downloaded scripts

{githubError?.message ?? localError?.message ?? "Unknown error occurred"}

); } if (!downloadedScripts?.length) { return (

No downloaded scripts found

You haven't downloaded any scripts yet. Visit the Available Scripts tab to download some scripts.

); } return (
{/* Category Sidebar */}
{/* Main Content */}
{/* Update all downloaded scripts */}
{updateResult && ( Updated {updateResult.successCount} successfully {updateResult.failCount > 0 ? `, ${updateResult.failCount} failed` : ""} . {updateResult.failCount > 0 && updateResult.failed.length > 0 && ( `${f.slug}: ${f.error}`).join("\n")}> (hover for details) )} )}
{/* Enhanced Filter Bar */} {/* View Toggle */} {/* Scripts Grid */} {filteredScripts.length === 0 && (filters.searchQuery || selectedCategory || filters.showUpdatable !== null || filters.selectedTypes.length > 0) ? (

No matching downloaded scripts found

Try different filter settings or clear all filters.

{filters.searchQuery && ( )} {selectedCategory && ( )}
) : viewMode === "card" ? (
{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 ( ); })}
) : (
{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 ( ); })}
)} 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" />
); }