From d40aeb6c8264be625d4a4c874ec3af482fe12a24 Mon Sep 17 00:00:00 2001 From: CanbiZ <47820557+MickLesk@users.noreply.github.com> Date: Fri, 28 Nov 2025 11:58:38 +0100 Subject: [PATCH] Refactor scripts grid and filter handling for robustness Improves type safety and normalization in filter, repository, and script status handling across multiple components. Refactors ScriptsGrid for better readability, deduplication, and error messaging, and updates UI markup for consistency. Also adds explicit types for auto-sync settings and ensures string conversion for credential fields. --- src/app/_components/DownloadedScriptsTab.tsx | 4 +- src/app/_components/FilterBar.tsx | 12 +- src/app/_components/GeneralSettingsModal.tsx | 18 +- src/app/_components/PBSCredentialsModal.tsx | 8 +- src/app/_components/ScriptsGrid.tsx | 831 +++++++++++-------- src/app/_components/ServerStoragesModal.tsx | 2 +- src/app/page.tsx | 5 +- 7 files changed, 538 insertions(+), 342 deletions(-) diff --git a/src/app/_components/DownloadedScriptsTab.tsx b/src/app/_components/DownloadedScriptsTab.tsx index 4d4c1e3..620c450 100644 --- a/src/app/_components/DownloadedScriptsTab.tsx +++ b/src/app/_components/DownloadedScriptsTab.tsx @@ -67,7 +67,9 @@ export function DownloadedScriptsTab({ if (saveFilterEnabled) { const filtersResponse = await fetch("/api/settings/filters"); if (filtersResponse.ok) { - const filtersData = await filtersResponse.json(); + const filtersData = (await filtersResponse.json()) as { + filters?: Partial; + }; if (filtersData.filters) { setFilters(mergeFiltersWithDefaults(filtersData.filters)); } diff --git a/src/app/_components/FilterBar.tsx b/src/app/_components/FilterBar.tsx index 0d1af37..03dd11e 100644 --- a/src/app/_components/FilterBar.tsx +++ b/src/app/_components/FilterBar.tsx @@ -322,9 +322,9 @@ export function FilterBar({ {/* Repository Filter Buttons - Only show if more than one enabled repo */} {enabledRepos.length > 1 && enabledRepos.map((repo) => { - const isSelected = filters.selectedRepositories.includes( - repo.url, - ); + const repoUrl = String(repo.url); + const isSelected = + filters.selectedRepositories.includes(repoUrl); return ( ); })} diff --git a/src/app/_components/GeneralSettingsModal.tsx b/src/app/_components/GeneralSettingsModal.tsx index c592c8b..f119737 100644 --- a/src/app/_components/GeneralSettingsModal.tsx +++ b/src/app/_components/GeneralSettingsModal.tsx @@ -11,6 +11,20 @@ import { api } from "~/trpc/react"; import { useAuth } from "./AuthProvider"; import { Trash2, ExternalLink } from "lucide-react"; +interface AutoSyncSettings { + autoSyncEnabled: boolean; + syncIntervalType: "predefined" | "custom"; + syncIntervalPredefined: string; + syncIntervalCron: string; + autoDownloadNew: boolean; + autoUpdateExisting: boolean; + notificationEnabled: boolean; + appriseUrls: string[]; + lastAutoSync: string; + lastAutoSyncError: string | null; + lastAutoSyncErrorTime: string | null; +} + interface GeneralSettingsModalProps { isOpen: boolean; onClose: () => void; @@ -482,7 +496,9 @@ export function GeneralSettingsModal({ try { const response = await fetch("/api/settings/auto-sync"); if (response.ok) { - const data = (await response.json()) as { settings: any }; + const data = (await response.json()) as { + settings: AutoSyncSettings | null; + }; const settings = data.settings; if (settings) { setAutoSyncEnabled(settings.autoSyncEnabled ?? false); diff --git a/src/app/_components/PBSCredentialsModal.tsx b/src/app/_components/PBSCredentialsModal.tsx index 9860072..c6c36cd 100644 --- a/src/app/_components/PBSCredentialsModal.tsx +++ b/src/app/_components/PBSCredentialsModal.tsx @@ -45,10 +45,12 @@ export function PBSCredentialsModal({ if (isOpen) { if (credentialData?.success && credentialData.credential) { // Load existing credentials - setPbsIp(credentialData.credential.pbs_ip); - setPbsDatastore(credentialData.credential.pbs_datastore); + setPbsIp(String(credentialData.credential.pbs_ip)); + setPbsDatastore(String(credentialData.credential.pbs_datastore)); setPbsPassword(""); // Don't show password - setPbsFingerprint(credentialData.credential.pbs_fingerprint ?? ""); + setPbsFingerprint( + String(credentialData.credential.pbs_fingerprint ?? ""), + ); } else { // Initialize with storage config values setPbsIp(pbsIpFromStorage ?? ""); diff --git a/src/app/_components/ScriptsGrid.tsx b/src/app/_components/ScriptsGrid.tsx index 32837f4..e229ce3 100644 --- a/src/app/_components/ScriptsGrid.tsx +++ b/src/app/_components/ScriptsGrid.tsx @@ -1,18 +1,17 @@ -'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'; +"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; @@ -21,22 +20,36 @@ interface ScriptsGridProps { export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) { const [selectedSlug, setSelectedSlug] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); - const [searchQuery, setSearchQuery] = useState(''); + const [searchQuery, setSearchQuery] = useState(""); const [selectedCategory, setSelectedCategory] = useState(null); - const [viewMode, setViewMode] = useState<'card' | 'list'>('card'); + 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 [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: 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 } + { slug: selectedSlug ?? "" }, + { enabled: !!selectedSlug }, ); // Individual script download mutation @@ -47,7 +60,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) { const loadSettings = async () => { try { // Load SAVE_FILTER setting - const saveFilterResponse = await fetch('/api/settings/save-filter'); + const saveFilterResponse = await fetch("/api/settings/save-filter"); let saveFilterEnabled = false; if (saveFilterResponse.ok) { const saveFilterData = await saveFilterResponse.json(); @@ -57,9 +70,11 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) { // Load saved filters if SAVE_FILTER is enabled if (saveFilterEnabled) { - const filtersResponse = await fetch('/api/settings/filters'); + const filtersResponse = await fetch("/api/settings/filters"); if (filtersResponse.ok) { - const filtersData = await filtersResponse.json(); + const filtersData = (await filtersResponse.json()) as { + filters?: Partial; + }; if (filtersData.filters) { setFilters(mergeFiltersWithDefaults(filtersData.filters)); } @@ -67,16 +82,20 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) { } // Load view mode - const viewModeResponse = await fetch('/api/settings/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')) { + if ( + viewMode && + typeof viewMode === "string" && + (viewMode === "card" || viewMode === "list") + ) { setViewMode(viewMode); } } } catch (error) { - console.error('Error loading settings:', error); + console.error("Error loading settings:", error); } finally { setIsLoadingFilters(false); } @@ -91,15 +110,15 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) { const saveFilters = async () => { try { - await fetch('/api/settings/filters', { - method: 'POST', + await fetch("/api/settings/filters", { + method: "POST", headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, body: JSON.stringify({ filters }), }); } catch (error) { - console.error('Error saving filters:', error); + console.error("Error saving filters:", error); } }; @@ -114,15 +133,15 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) { const saveViewMode = async () => { try { - await fetch('/api/settings/view-mode', { - method: 'POST', + await fetch("/api/settings/view-mode", { + method: "POST", headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, body: JSON.stringify({ viewMode }), }); } catch (error) { - console.error('Error saving view mode:', error); + console.error("Error saving view mode:", error); } }; @@ -133,31 +152,32 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) { // Extract categories from metadata const categories = React.useMemo((): string[] => { - if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return []; - + 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'); + .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 => { + + 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, + source: "github" as const, isDownloaded: false, // Will be updated by status check - isUpToDate: false, // Will be updated by status check + isUpToDate: false, // Will be updated by status check }); } } @@ -169,63 +189,71 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) { // 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 => { + 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)) { + 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, ''); + 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 => { + 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; + 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; - + + // 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, @@ -237,12 +265,12 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) { // Check if any filters are active (excluding default state) const hasActiveFilters = React.useMemo(() => { return ( - filters.searchQuery?.trim() !== '' || + filters.searchQuery?.trim() !== "" || filters.showUpdatable !== null || filters.selectedTypes.length > 0 || filters.selectedRepositories.length > 0 || - filters.sortBy !== 'name' || - filters.sortOrder !== 'asc' || + filters.sortBy !== "name" || + filters.sortOrder !== "asc" || selectedCategory !== null ); }, [filters, selectedCategory]); @@ -250,10 +278,10 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) { // 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 + .filter((script) => script?.date_created) // Only scripts with date_created .sort((a, b) => { - const aCreated = a?.date_created ?? ''; - const bCreated = b?.date_created ?? ''; + const aCreated = a?.date_created ?? ""; + const bCreated = b?.date_created ?? ""; // Sort by date descending (newest first) return bCreated.localeCompare(aCreated); }) @@ -267,15 +295,15 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) { // 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') { + scripts = scripts.filter((script) => { + if (!script || typeof script !== "object") { return false; } - const name = (script.name ?? '').toLowerCase(); - const slug = (script.slug ?? '').toLowerCase(); + const name = (script.name ?? "").toLowerCase(); + const slug = (script.slug ?? "").toLowerCase(); return name.includes(query) ?? slug.includes(query); }); @@ -284,9 +312,9 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) { // Filter by category using real category data from deduplicated scripts if (selectedCategory) { - scripts = scripts.filter(script => { + 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; }); @@ -294,7 +322,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) { // Filter by updateable status if (filters.showUpdatable !== null) { - scripts = scripts.filter(script => { + scripts = scripts.filter((script) => { if (!script) return false; const isUpdatable = script.updateable ?? false; return filters.showUpdatable ? isUpdatable : !isUpdatable; @@ -303,28 +331,30 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) { // Filter by script types if (filters.selectedTypes.length > 0) { - scripts = scripts.filter(script => { + scripts = scripts.filter((script) => { if (!script) return false; - const scriptType = (script.type ?? '').toLowerCase(); - + 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); + 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 => { + 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); }); @@ -332,25 +362,27 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) { // 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)); + 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 ?? ''); + case "name": + compareValue = (a.name ?? "").localeCompare(b.name ?? ""); break; - case 'created': + 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 ?? ''; - + 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) @@ -363,32 +395,42 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) { compareValue = 1; } else { // Both have no dates, fallback to name comparison - compareValue = (a.name ?? '').localeCompare(b.name ?? ''); + compareValue = (a.name ?? "").localeCompare(b.name ?? ""); } break; default: - compareValue = (a.name ?? '').localeCompare(b.name ?? ''); + compareValue = (a.name ?? "").localeCompare(b.name ?? ""); } - + // Apply sort order - return filters.sortOrder === 'asc' ? compareValue : -compareValue; + return filters.sortOrder === "asc" ? compareValue : -compareValue; }); return scripts; - }, [scriptsWithStatus, filters, selectedCategory, hasActiveFilters, newestScripts]); + }, [ + 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; - + 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 })); + setFilters((prev) => ({ ...prev, searchQuery })); } }, [searchQuery, filters.searchQuery]); @@ -401,7 +443,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) { // Selection management functions const toggleScriptSelection = (slug: string) => { - setSelectedSlugs(prev => { + setSelectedSlugs((prev) => { const newSet = new Set(prev); if (newSet.has(slug)) { newSet.delete(slug); @@ -413,7 +455,9 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) { }; const selectAllVisible = () => { - const visibleSlugs = new Set(filteredScripts.map(script => script.slug).filter(Boolean)); + const visibleSlugs = new Set( + filteredScripts.map((script) => script.slug).filter(Boolean), + ); setSelectedSlugs(visibleSlugs); }; @@ -423,125 +467,155 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) { const getFriendlyErrorMessage = (error: string, slug: string): string => { const errorLower = error.toLowerCase(); - + // Exact matches first (most specific) - if (error === 'Script not found') { + 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') { + + 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.'; + 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')) { + 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("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.'; + + 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("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("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.'; + + 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')) { + if (errorLower.includes("script not found")) { return `Script "${slug}" not found in the local scripts directory.`; } - - if (errorLower.includes('invalid script') || errorLower.includes('malformed')) { + + 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')) { + + 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("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 (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: [] }); - + 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); - + setDownloadProgress((prev) => + prev + ? { + ...prev, + current: i, + currentScript: slug ?? "", + } + : null, + ); + try { // Download individual script - const result = await loadSingleScriptMutation.mutateAsync({ slug: slug ?? '' }); - + const result = await loadSingleScriptMutation.mutateAsync({ + slug: slug ?? "", + }); + if (result.success) { - successful.push({ slug: slug ?? '', files: result.files ?? [] }); + 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 }); + 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 + 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); - + 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 }; @@ -554,30 +628,29 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) { }; const handleDownloadAllFiltered = () => { - let scriptsToDownload: ScriptCardType[] = filteredScripts; - + if (!hasActiveFilters) { - const scriptMap = new Map(); - filteredScripts.forEach(script => { + filteredScripts.forEach((script) => { if (script?.slug) { scriptMap.set(script.slug, script); } }); - - newestScripts.forEach(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); + + const slugsToDownload = scriptsToDownload + .map((script) => script.slug) + .filter(Boolean); if (slugsToDownload.length > 0) { void downloadScriptsIndividually(slugsToDownload); } @@ -592,13 +665,13 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) { useEffect(() => { if (selectedCategory && gridRef.current) { const timeoutId = setTimeout(() => { - gridRef.current?.scrollIntoView({ - behavior: 'smooth', - block: 'start', - inline: 'nearest' + gridRef.current?.scrollIntoView({ + behavior: "smooth", + block: "start", + inline: "nearest", }); }, 100); - + return () => clearTimeout(timeoutId); } }, [selectedCategory]); @@ -615,7 +688,6 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) { }; }, []); - const handleCardClick = (scriptCard: ScriptCardType) => { // All scripts are GitHub scripts, open modal setSelectedSlug(scriptCard.slug); @@ -630,22 +702,34 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) { if (githubLoading || localLoading) { return (
-
- Loading scripts... +
+ Loading scripts...
); } if (githubError || localError) { return ( -
+
- - + +

Failed to load scripts

-

- {githubError?.message ?? localError?.message ?? 'Unknown error occurred'} +

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

- + {!isNewestMinimized && ( -
-
+
+
{newestScripts.map((script, index) => { - if (!script || typeof script !== 'object') { + if (!script || typeof script !== "object") { return null; } - - const uniqueKey = `newest-${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`; - + + const uniqueKey = `newest-${script.slug ?? "unknown"}-${script.name ?? "unnamed"}-${index}`; + return ( -
+
{/* NEW badge */} -
+
NEW
@@ -780,7 +881,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) { )} {/* Action Buttons */} -
+
{selectedSlugs.size > 0 ? ( )} {filteredScripts.length > 0 && ( - )} @@ -839,21 +935,28 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) { {/* Progress Bar */} {downloadProgress && ( -
-
+
+
- - {downloadProgress.current >= downloadProgress.total ? 'Download completed' : 'Downloading scripts'}... {downloadProgress.current} of {downloadProgress.total} + + {downloadProgress.current >= downloadProgress.total + ? "Download completed" + : "Downloading scripts"} + ... {downloadProgress.current} of {downloadProgress.total} - {downloadProgress.currentScript && downloadProgress.current < downloadProgress.total && ( - - Currently downloading: {downloadProgress.currentScript} - - )} + {downloadProgress.currentScript && + downloadProgress.current < downloadProgress.total && ( + + Currently downloading: {downloadProgress.currentScript} + + )}
- - {Math.round((downloadProgress.current / downloadProgress.total) * 100)}% + + {Math.round( + (downloadProgress.current / downloadProgress.total) * 100, + )} + % {downloadProgress.current >= downloadProgress.total && ( )}
- + {/* Progress Bar */} -
-
+
0 ? 'bg-warning' : 'bg-primary' + downloadProgress.failed.length > 0 + ? "bg-warning" + : "bg-primary" }`} - style={{ width: `${(downloadProgress.current / downloadProgress.total) * 100}%` }} + 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); - + const isFailed = downloadProgress.failed.some( + (f) => f.slug === downloadProgress.currentScript, + ); + return ( - - {isCompleted ? (isFailed ? '✗' : '✓') : isCurrent ? '⟳' : '○'} + {isCompleted + ? isFailed + ? "✗" + : "✓" + : isCurrent + ? "⟳" + : "○"} ); })}
- + {/* Failed Scripts Details */} {downloadProgress.failed.length > 0 && ( -
-
- - +
+
+ + - + Failed Downloads ({downloadProgress.failed.length})
{downloadProgress.failed.map((failed, index) => ( -
- {failed.slug}: {failed.error} +
+ {failed.slug}:{" "} + {failed.error}
))}
@@ -930,11 +1064,21 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) { )} {/* 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" + 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}"` : ''} + + 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}"` : ''} + Found {filteredScripts.length} script + {filteredScripts.length !== 1 ? "s" : ""} + {searchQuery ? ` matching "${searchQuery}"` : ""} + {selectedCategory ? ` in category "${selectedCategory}"` : ""} )}
@@ -971,20 +1130,36 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
{/* Scripts Grid */} - {filteredScripts.length === 0 && (filters.searchQuery || selectedCategory || filters.showUpdatable !== null || filters.selectedTypes.length > 0) ? ( -
+ {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 && (
- ) : ( - viewMode === 'card' ? ( -
- {filteredScripts.map((script, index) => { + ) : viewMode === "card" ? ( +
+ {filteredScripts.map((script, index) => { // Add validation to ensure script has required properties - if (!script || typeof script !== 'object') { + 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}`; - + const uniqueKey = `${script.slug ?? "unknown"}-${script.name ?? "unnamed"}-${index}`; + return ( ); })} -
- ) : ( -
- {filteredScripts.map((script, index) => { +
+ ) : ( +
+ {filteredScripts.map((script, index) => { // Add validation to ensure script has required properties - if (!script || typeof script !== 'object') { + 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}`; - + const uniqueKey = `${script.slug ?? "unknown"}-${script.name ?? "unnamed"}-${index}`; + return ( ); })} -
- ) +
)} (); if (allCredentials?.success) { allCredentials.credentials.forEach((c) => { - credentialsMap.set(c.storage_name, true); + credentialsMap.set(String(c.storage_name), true); }); } diff --git a/src/app/page.tsx b/src/app/page.tsx index a802351..1d00a44 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -161,8 +161,11 @@ export default function Home() { // Secondary: Check install basenames (for edge cases where install script names differ from slugs) const normalizedLocal = normalizeId(local.name); + const scriptWithBasenames = script as { + install_basenames?: string[]; + }; const matchesInstallBasename = - (script as any)?.install_basenames?.some( + scriptWithBasenames.install_basenames?.some( (base: string) => normalizeId(base) === normalizedLocal, ) ?? false; if (matchesInstallBasename) return true;