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;