feat: Multi-select script download with progress tracking (#121)
* feat: Add multi-select script download with progress tracking - Add checkbox selection to script cards (both card and list views) - Implement individual script downloads with real-time progress - Add progress bar with visual indicators (✓ success, ✗ failed, ⟳ in-progress) - Add batch download buttons (Download Selected, Download All Filtered) - Add user-friendly error messages with specific guidance - Add persistent progress bar with manual dismiss option - Clear selection when switching between card/list views - Update card download status immediately after completion Features: - Multi-select with checkboxes on script cards - Real-time progress tracking during downloads - Detailed error reporting with actionable messages - Visual progress indicators for each script - Batch download functionality for selected or filtered scripts - Persistent progress bar until manually dismissed - Automatic card status updates after download completion * fix: Resolve ESLint errors - Replace logical OR operators (||) with nullish coalescing (??) for safer null/undefined handling - Replace for loop with for-of loop for better iteration - Remove unused variable 'results' - Fix all TypeScript ESLint warnings and errors * fix: Resolve TypeScript error in loadMultipleScripts - Use type guard to check for 'error' property before accessing - Fix 'Property error does not exist' TypeScript error - Ensure safe access to error property in result object
This commit is contained in:
committed by
GitHub
parent
9d83697d45
commit
24afce49a3
@@ -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}"
|
||||
@@ -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"
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className="bg-card rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer border border-border hover:border-primary h-full flex flex-col"
|
||||
className="bg-card rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer border border-border hover:border-primary h-full flex flex-col relative"
|
||||
onClick={() => onClick(script)}
|
||||
>
|
||||
{/* Checkbox in top-left corner */}
|
||||
{onToggleSelect && (
|
||||
<div className="absolute top-2 left-2 z-10">
|
||||
<div
|
||||
className={`w-4 h-4 border-2 rounded cursor-pointer transition-all duration-200 flex items-center justify-center ${
|
||||
isSelected
|
||||
? 'bg-primary border-primary text-primary-foreground'
|
||||
: 'bg-card border-border hover:border-primary/60 hover:bg-accent'
|
||||
}`}
|
||||
onClick={handleCheckboxClick}
|
||||
>
|
||||
{isSelected && (
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-6 flex-1 flex flex-col">
|
||||
{/* Header with logo and name */}
|
||||
<div className="flex items-start space-x-4 mb-4">
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className="bg-card rounded-lg shadow-sm hover:shadow-md transition-shadow duration-200 cursor-pointer border border-border hover:border-primary"
|
||||
className="bg-card rounded-lg shadow-sm hover:shadow-md transition-shadow duration-200 cursor-pointer border border-border hover:border-primary relative"
|
||||
onClick={() => onClick(script)}
|
||||
>
|
||||
<div className="p-6">
|
||||
{/* Checkbox */}
|
||||
{onToggleSelect && (
|
||||
<div className="absolute top-4 left-4 z-10">
|
||||
<div
|
||||
className={`w-4 h-4 border-2 rounded cursor-pointer transition-all duration-200 flex items-center justify-center ${
|
||||
isSelected
|
||||
? 'bg-primary border-primary text-primary-foreground'
|
||||
: 'bg-card border-border hover:border-primary/60 hover:bg-accent'
|
||||
}`}
|
||||
onClick={handleCheckboxClick}
|
||||
>
|
||||
{isSelected && (
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`p-6 ${onToggleSelect ? 'pl-12' : ''}`}>
|
||||
<div className="flex items-start space-x-4">
|
||||
{/* Logo */}
|
||||
<div className="flex-shrink-0">
|
||||
|
||||
@@ -22,6 +22,8 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<'card' | 'list'>('card');
|
||||
const [selectedSlugs, setSelectedSlugs] = useState<Set<string>>(new Set());
|
||||
const [downloadProgress, setDownloadProgress] = useState<{ current: number; total: number; currentScript: string; failed: Array<{ slug: string; error: string }> } | null>(null);
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
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 */}
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{selectedSlugs.size > 0 ? (
|
||||
<Button
|
||||
onClick={handleBatchDownload}
|
||||
disabled={loadSingleScriptMutation.isPending}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
{loadSingleScriptMutation.isPending ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Downloading...
|
||||
</>
|
||||
) : (
|
||||
`Download Selected (${selectedSlugs.size})`
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleDownloadAllFiltered}
|
||||
disabled={filteredScripts.length === 0 || loadSingleScriptMutation.isPending}
|
||||
variant="outline"
|
||||
>
|
||||
{loadSingleScriptMutation.isPending ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current mr-2"></div>
|
||||
Downloading...
|
||||
</>
|
||||
) : (
|
||||
`Download All Filtered (${filteredScripts.length})`
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{selectedSlugs.size > 0 && (
|
||||
<Button
|
||||
onClick={clearSelection}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
>
|
||||
Clear Selection
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{filteredScripts.length > 0 && (
|
||||
<Button
|
||||
onClick={selectAllVisible}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
>
|
||||
Select All Visible
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{downloadProgress && (
|
||||
<div className="mb-4 p-4 bg-card border border-border rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{downloadProgress.current >= downloadProgress.total ? 'Download completed' : 'Downloading scripts'}... {downloadProgress.current} of {downloadProgress.total}
|
||||
</span>
|
||||
{downloadProgress.currentScript && downloadProgress.current < downloadProgress.total && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Currently downloading: {downloadProgress.currentScript}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{Math.round((downloadProgress.current / downloadProgress.total) * 100)}%
|
||||
</span>
|
||||
{downloadProgress.current >= downloadProgress.total && (
|
||||
<button
|
||||
onClick={() => setDownloadProgress(null)}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Dismiss progress bar"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="w-full bg-muted rounded-full h-2 mb-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all duration-300 ease-out ${
|
||||
downloadProgress.failed.length > 0 ? 'bg-yellow-500' : 'bg-primary'
|
||||
}`}
|
||||
style={{ width: `${(downloadProgress.current / downloadProgress.total) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Progress Visualization */}
|
||||
<div className="flex items-center text-xs text-muted-foreground mb-2">
|
||||
<span className="mr-2">Progress:</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{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 (
|
||||
<span
|
||||
key={i}
|
||||
className={`px-1 py-0.5 rounded text-xs ${
|
||||
isCompleted
|
||||
? isFailed ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300' : 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
|
||||
: isCurrent
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300 animate-pulse'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{isCompleted ? (isFailed ? '✗' : '✓') : isCurrent ? '⟳' : '○'}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Failed Scripts Details */}
|
||||
{downloadProgress.failed.length > 0 && (
|
||||
<div className="mt-3 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<div className="flex items-center mb-2">
|
||||
<svg className="w-4 h-4 text-red-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||
Failed Downloads ({downloadProgress.failed.length})
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{downloadProgress.failed.map((failed, index) => (
|
||||
<div key={index} className="text-xs text-red-700 dark:text-red-300">
|
||||
<span className="font-medium">{failed.slug}:</span> {failed.error}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legacy Search Bar (keeping for backward compatibility, but hidden) */}
|
||||
<div className="hidden mb-8">
|
||||
<div className="relative max-w-md mx-auto">
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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() }))
|
||||
|
||||
Reference in New Issue
Block a user