diff --git a/scripts/ct/debian.sh b/scripts/ct/debian.sh deleted file mode 100644 index 54b1748..0000000 --- a/scripts/ct/debian.sh +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env bash -SCRIPT_DIR="$(dirname "$0")" -source "$SCRIPT_DIR/../core/build.func" -# Copyright (c) 2021-2025 tteck -# Author: tteck (tteckster) -# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE -# Source: https://www.debian.org/ - -APP="Debian" -var_tags="${var_tags:-os}" -var_cpu="${var_cpu:-1}" -var_ram="${var_ram:-512}" -var_disk="${var_disk:-2}" -var_os="${var_os:-debian}" -var_version="${var_version:-13}" -var_unprivileged="${var_unprivileged:-1}" - -header_info "$APP" -variables -color -catch_errors - -function update_script() { - header_info - check_container_storage - check_container_resources - if [[ ! -d /var ]]; then - msg_error "No ${APP} Installation Found!" - exit - fi - msg_info "Updating $APP LXC" - $STD apt update - $STD apt -y upgrade - msg_ok "Updated $APP LXC" - exit -} - -start -build_container -description - -msg_ok "Completed Successfully!\n" -echo -e "${CREATING}${GN}${APP} setup has been successfully initialized!${CL}" diff --git a/scripts/install/debian-install.sh b/scripts/install/debian-install.sh deleted file mode 100644 index 7b00eca..0000000 --- a/scripts/install/debian-install.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash - -# Copyright (c) 2021-2025 tteck -# Author: tteck (tteckster) -# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE -# Source: https://www.debian.org/ - -source /dev/stdin <<<"$FUNCTIONS_FILE_PATH" -color -verb_ip6 -catch_errors -setting_up_container -network_check -update_os - -motd_ssh -customize - -msg_info "Cleaning up" -$STD apt -y autoremove -$STD apt -y autoclean -$STD apt -y clean -msg_ok "Cleaned" - diff --git a/src/app/_components/ScriptCard.tsx b/src/app/_components/ScriptCard.tsx index 3b8d407..2929954 100644 --- a/src/app/_components/ScriptCard.tsx +++ b/src/app/_components/ScriptCard.tsx @@ -8,20 +8,49 @@ import { TypeBadge, UpdateableBadge } from './Badge'; interface ScriptCardProps { script: ScriptCard; onClick: (script: ScriptCard) => void; + isSelected?: boolean; + onToggleSelect?: (slug: string) => void; } -export function ScriptCard({ script, onClick }: ScriptCardProps) { +export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect }: ScriptCardProps) { const [imageError, setImageError] = useState(false); const handleImageError = () => { setImageError(true); }; + const handleCheckboxClick = (e: React.MouseEvent) => { + e.stopPropagation(); + if (onToggleSelect && script.slug) { + onToggleSelect(script.slug); + } + }; + return (
onClick(script)} > + {/* Checkbox in top-left corner */} + {onToggleSelect && ( +
+
+ {isSelected && ( + + + + )} +
+
+ )} +
{/* Header with logo and name */}
diff --git a/src/app/_components/ScriptCardList.tsx b/src/app/_components/ScriptCardList.tsx index 21fde12..78b63b2 100644 --- a/src/app/_components/ScriptCardList.tsx +++ b/src/app/_components/ScriptCardList.tsx @@ -8,15 +8,24 @@ import { TypeBadge, UpdateableBadge } from './Badge'; interface ScriptCardListProps { script: ScriptCard; onClick: (script: ScriptCard) => void; + isSelected?: boolean; + onToggleSelect?: (slug: string) => void; } -export function ScriptCardList({ script, onClick }: ScriptCardListProps) { +export function ScriptCardList({ script, onClick, isSelected = false, onToggleSelect }: ScriptCardListProps) { const [imageError, setImageError] = useState(false); const handleImageError = () => { setImageError(true); }; + const handleCheckboxClick = (e: React.MouseEvent) => { + e.stopPropagation(); + if (onToggleSelect && script.slug) { + onToggleSelect(script.slug); + } + }; + const formatDate = (dateString?: string) => { if (!dateString) return 'Unknown'; try { @@ -37,10 +46,30 @@ export function ScriptCardList({ script, onClick }: ScriptCardListProps) { return (
onClick(script)} > -
+ {/* Checkbox */} + {onToggleSelect && ( +
+
+ {isSelected && ( + + + + )} +
+
+ )} + +
{/* Logo */}
diff --git a/src/app/_components/ScriptsGrid.tsx b/src/app/_components/ScriptsGrid.tsx index a01c266..59873ba 100644 --- a/src/app/_components/ScriptsGrid.tsx +++ b/src/app/_components/ScriptsGrid.tsx @@ -22,6 +22,8 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) { 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, @@ -40,6 +42,9 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) { { 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 () => { @@ -328,6 +333,168 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) { 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'; + console.log(`Script ${slug} failed with error:`, error, 'Full result:', result); + 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 = () => { + const slugsToDownload = filteredScripts.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); @@ -348,6 +515,18 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) { } }, [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: { slug: string }) => { // All scripts are GitHub scripts, open modal @@ -441,6 +620,154 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) { onViewModeChange={setViewMode} /> + + {/* 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-yellow-500' : '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) */}
@@ -532,6 +859,8 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) { key={uniqueKey} script={script} onClick={handleCardClick} + isSelected={selectedSlugs.has(script.slug ?? '')} + onToggleSelect={toggleScriptSelection} /> ); })} @@ -552,6 +881,8 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) { key={uniqueKey} script={script} onClick={handleCardClick} + isSelected={selectedSlugs.has(script.slug ?? '')} + onToggleSelect={toggleScriptSelection} /> ); })} diff --git a/src/server/api/routers/scripts.ts b/src/server/api/routers/scripts.ts index 3b27e42..03185a8 100644 --- a/src/server/api/routers/scripts.ts +++ b/src/server/api/routers/scripts.ts @@ -254,6 +254,58 @@ export const scriptsRouter = createTRPCRouter({ } }), + // Load multiple scripts from GitHub + loadMultipleScripts: publicProcedure + .input(z.object({ slugs: z.array(z.string()) })) + .mutation(async ({ input }) => { + try { + const successful = []; + const failed = []; + + for (const slug of input.slugs) { + try { + // Get the script details + const script = await localScriptsService.getScriptBySlug(slug); + if (!script) { + failed.push({ slug, error: 'Script not found' }); + continue; + } + + // Load the script files + const result = await scriptDownloaderService.loadScript(script); + if (result.success) { + successful.push({ slug, files: result.files }); + } else { + const error = 'error' in result ? result.error : 'Failed to load script'; + failed.push({ slug, error }); + } + } catch (error) { + failed.push({ + slug, + error: error instanceof Error ? error.message : 'Failed to load script' + }); + } + } + + return { + success: true, + message: `Downloaded ${successful.length} scripts successfully, ${failed.length} failed`, + successful, + failed, + total: input.slugs.length + }; + } catch (error) { + console.error('Error in loadMultipleScripts:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to load multiple scripts', + successful: [], + failed: [], + total: 0 + }; + } + }), + // Check if script files exist locally checkScriptFiles: publicProcedure .input(z.object({ slug: z.string() }))