'use client'; import React, { useState, useRef, useEffect } from 'react'; import { api } from '~/trpc/react'; import { ScriptCard } from './ScriptCard'; import { ScriptDetailModal } from './ScriptDetailModal'; import { CategorySidebar } from './CategorySidebar'; import { FilterBar, type FilterState } from './FilterBar'; import type { ScriptCard as ScriptCardType } from '~/types/script'; 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 [filters, setFilters] = useState({ searchQuery: '', showUpdatable: null, selectedTypes: [], sortBy: 'name', sortOrder: 'asc', }); 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.getCtScripts.useQuery(); const { data: scriptData } = api.scripts.getScriptBySlug.useQuery( { slug: selectedSlug ?? '' }, { enabled: !!selectedSlug } ); // 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 => { 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[] => { 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; const localName = local.name.replace(/\.sh$/, ''); return localName.toLowerCase() === script.name.toLowerCase() || localName.toLowerCase() === (script.slug ?? '').toLowerCase(); }) ?? false; return { ...script, isDownloaded: hasLocalVersion, // Removed isUpToDate - only show in modal for detailed comparison }; }); }, [combinedScripts, localScriptsData]); // 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(); return filters.selectedTypes.some(type => type.toLowerCase() === scriptType); }); } // 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]); // 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); }; // 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: { slug: string }) => { // 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 || scriptsWithStatus.length === 0) { return (

No scripts found

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

); } return (
{/* Category Sidebar */}
{/* Main Content */}
{/* Enhanced Filter Bar */} {/* Legacy Search Bar (keeping for backward compatibility, but hidden) */}
setSearchQuery(e.target.value)} className="block w-full pl-10 pr-3 py-3 border border-gray-300 dark:border-gray-600 rounded-lg leading-5 bg-white dark:bg-gray-800 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-gray-100 focus:outline-none focus:placeholder-gray-400 dark:focus:placeholder-gray-300 focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 text-sm" /> {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 && ( )}
) : (
{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 ( ); })}
)}
); }