"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 { Button } from "./ui/button"; import { Clock } from "lucide-react"; import type { ScriptCard as ScriptCardType } from "~/types/script"; import { getDefaultFilters, mergeFiltersWithDefaults } from "./filterUtils"; interface ScriptsGridProps { onInstallScript?: (scriptPath: string, scriptName: string) => void; } export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) { const [selectedSlug, setSelectedSlug] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [selectedCategory, setSelectedCategory] = useState(null); const [viewMode, setViewMode] = useState<"card" | "list">("card"); const [selectedSlugs, setSelectedSlugs] = useState>(new Set()); const [downloadProgress, setDownloadProgress] = useState<{ current: number; total: number; currentScript: string; failed: Array<{ slug: string; error: string }>; } | null>(null); const [filters, setFilters] = useState(getDefaultFilters()); const [saveFiltersEnabled, setSaveFiltersEnabled] = useState(false); const [isLoadingFilters, setIsLoadingFilters] = useState(true); const [isNewestMinimized, setIsNewestMinimized] = useState(false); const gridRef = useRef(null); 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 }, ); // 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; }; 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]); // Count scripts per category (using deduplicated scripts) 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 script only once per category combinedScripts.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, 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" || selectedCategory !== null ); }, [filters, selectedCategory]); // Get the 6 newest scripts based on date_created field const newestScripts = React.useMemo((): ScriptCardType[] => { return scriptsWithStatus .filter((script) => script?.date_created) // Only scripts with date_created .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]); // Filter scripts based on all filters and category const filteredScripts = React.useMemo((): ScriptCardType[] => { let scripts = scriptsWithStatus; // 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 (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(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); }); } // 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; default: compareValue = (a.name ?? "").localeCompare(b.name ?? ""); } // Apply sort order return filters.sortOrder === "asc" ? compareValue : -compareValue; }); return scripts; }, [ scriptsWithStatus, filters, selectedCategory, hasActiveFilters, newestScripts, ]); // 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 = (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[]) => { 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++) { 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(); // 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(); 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) => { 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]); // 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 = (scriptCard: ScriptCardType) => { // All scripts are GitHub scripts, open modal setSelectedSlug(scriptCard.slug); setIsModalOpen(true); }; const handleCloseModal = () => { setIsModalOpen(false); setSelectedSlug(null); }; if (githubLoading || localLoading) { return (
Loading scripts...
); } if (githubError || localError) { return (

Failed to load scripts

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

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

No scripts found

No script files were found in the repository or local directory.

); } return (
{/* Category Sidebar */}
{/* Main Content */}
{/* Enhanced Filter Bar */} {/* View Toggle */} {/* Newest Scripts Carousel - Only show when no search, filters, or category is active */} {newestScripts.length > 0 && !hasActiveFilters && !selectedCategory && (

Newest Scripts

{newestScripts.length} recently added
{!isNewestMinimized && (
{newestScripts.map((script, index) => { if (!script || typeof script !== "object") { return null; } const uniqueKey = `newest-${script.slug ?? "unknown"}-${script.name ?? "unnamed"}-${index}`; return (
{/* NEW badge */}
NEW
); })}
)}
)} {/* Action Buttons */}
{selectedSlugs.size > 0 ? ( ) : ( )} {selectedSlugs.size > 0 && ( )} {filteredScripts.length > 0 && ( )}
{/* Progress Bar */} {downloadProgress && (
{downloadProgress.current >= downloadProgress.total ? "Download completed" : "Downloading scripts"} ... {downloadProgress.current} of {downloadProgress.total} {downloadProgress.currentScript && downloadProgress.current < downloadProgress.total && ( Currently downloading: {downloadProgress.currentScript} )}
{Math.round( (downloadProgress.current / downloadProgress.total) * 100, )} % {downloadProgress.current >= downloadProgress.total && ( )}
{/* Progress Bar */}
0 ? "bg-warning" : "bg-primary" }`} style={{ width: `${(downloadProgress.current / downloadProgress.total) * 100}%`, }} />
{/* Progress Visualization */}
Progress:
{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 ( {isCompleted ? isFailed ? "✗" : "✓" : isCurrent ? "⟳" : "○"} ); })}
{/* Failed Scripts Details */} {downloadProgress.failed.length > 0 && (
Failed Downloads ({downloadProgress.failed.length})
{downloadProgress.failed.map((failed, index) => (
{failed.slug}:{" "} {failed.error}
))}
)}
)} {/* Legacy Search Bar (keeping for backward compatibility, but hidden) */}
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 && ( )}
{(searchQuery || selectedCategory) && (
{filteredScripts.length === 0 ? ( No scripts found {searchQuery ? ` matching "${searchQuery}"` : ""} {selectedCategory ? ` in category "${selectedCategory}"` : ""} ) : ( Found {filteredScripts.length} script {filteredScripts.length !== 1 ? "s" : ""} {searchQuery ? ` matching "${searchQuery}"` : ""} {selectedCategory ? ` in category "${selectedCategory}"` : ""} )}
)}
{/* Scripts Grid */} {filteredScripts.length === 0 && (filters.searchQuery || selectedCategory || filters.showUpdatable !== null || filters.selectedTypes.length > 0) ? (

No matching 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 ( ); })}
)}
); }