'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'; 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({ searchQuery: '', showUpdatable: null, selectedTypes: [], sortBy: 'name', sortOrder: 'asc', }); 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(); if (filtersData.filters) { setFilters(filtersData.filters as FilterState); } } } // 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 => { 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; const normalizedLocal = normalizeId(local.name); const matchesNameOrSlug = ( normalizedLocal === normalizeId(script.name) || normalizedLocal === normalizeId(script.slug) ); const matchesInstallBasename = (script as any)?.install_basenames?.some((base: string) => normalizeId(base) === normalizedLocal) ?? false; return matchesNameOrSlug || 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.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); }); } // 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, 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="block w-full pl-10 pr-3 py-3 border border-border rounded-lg leading-5 bg-card placeholder-muted-foreground text-foreground focus:outline-none focus:placeholder-muted-foreground focus:ring-2 focus:ring-ring focus:border-ring 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 && ( )}
) : ( 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 ( ); })}
) )}
); }