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 {
|
interface ScriptCardProps {
|
||||||
script: ScriptCard;
|
script: ScriptCard;
|
||||||
onClick: (script: ScriptCard) => void;
|
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 [imageError, setImageError] = useState(false);
|
||||||
|
|
||||||
const handleImageError = () => {
|
const handleImageError = () => {
|
||||||
setImageError(true);
|
setImageError(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCheckboxClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (onToggleSelect && script.slug) {
|
||||||
|
onToggleSelect(script.slug);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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)}
|
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">
|
<div className="p-6 flex-1 flex flex-col">
|
||||||
{/* Header with logo and name */}
|
{/* Header with logo and name */}
|
||||||
<div className="flex items-start space-x-4 mb-4">
|
<div className="flex items-start space-x-4 mb-4">
|
||||||
|
|||||||
@@ -8,15 +8,24 @@ import { TypeBadge, UpdateableBadge } from './Badge';
|
|||||||
interface ScriptCardListProps {
|
interface ScriptCardListProps {
|
||||||
script: ScriptCard;
|
script: ScriptCard;
|
||||||
onClick: (script: ScriptCard) => void;
|
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 [imageError, setImageError] = useState(false);
|
||||||
|
|
||||||
const handleImageError = () => {
|
const handleImageError = () => {
|
||||||
setImageError(true);
|
setImageError(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCheckboxClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (onToggleSelect && script.slug) {
|
||||||
|
onToggleSelect(script.slug);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const formatDate = (dateString?: string) => {
|
const formatDate = (dateString?: string) => {
|
||||||
if (!dateString) return 'Unknown';
|
if (!dateString) return 'Unknown';
|
||||||
try {
|
try {
|
||||||
@@ -37,10 +46,30 @@ export function ScriptCardList({ script, onClick }: ScriptCardListProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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)}
|
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">
|
<div className="flex items-start space-x-4">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
|||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||||
const [viewMode, setViewMode] = useState<'card' | 'list'>('card');
|
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>({
|
const [filters, setFilters] = useState<FilterState>({
|
||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
showUpdatable: null,
|
showUpdatable: null,
|
||||||
@@ -40,6 +42,9 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
|||||||
{ enabled: !!selectedSlug }
|
{ enabled: !!selectedSlug }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Individual script download mutation
|
||||||
|
const loadSingleScriptMutation = api.scripts.loadScript.useMutation();
|
||||||
|
|
||||||
// Load SAVE_FILTER setting, saved filters, and view mode on component mount
|
// Load SAVE_FILTER setting, saved filters, and view mode on component mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadSettings = async () => {
|
const loadSettings = async () => {
|
||||||
@@ -328,6 +333,168 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
|||||||
setSearchQuery(newFilters.searchQuery);
|
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
|
// Handle category selection with auto-scroll
|
||||||
const handleCategorySelect = (category: string | null) => {
|
const handleCategorySelect = (category: string | null) => {
|
||||||
setSelectedCategory(category);
|
setSelectedCategory(category);
|
||||||
@@ -348,6 +515,18 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
|||||||
}
|
}
|
||||||
}, [selectedCategory]);
|
}, [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 }) => {
|
const handleCardClick = (scriptCard: { slug: string }) => {
|
||||||
// All scripts are GitHub scripts, open modal
|
// All scripts are GitHub scripts, open modal
|
||||||
@@ -441,6 +620,154 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
|||||||
onViewModeChange={setViewMode}
|
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) */}
|
{/* Legacy Search Bar (keeping for backward compatibility, but hidden) */}
|
||||||
<div className="hidden mb-8">
|
<div className="hidden mb-8">
|
||||||
<div className="relative max-w-md mx-auto">
|
<div className="relative max-w-md mx-auto">
|
||||||
@@ -532,6 +859,8 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
|||||||
key={uniqueKey}
|
key={uniqueKey}
|
||||||
script={script}
|
script={script}
|
||||||
onClick={handleCardClick}
|
onClick={handleCardClick}
|
||||||
|
isSelected={selectedSlugs.has(script.slug ?? '')}
|
||||||
|
onToggleSelect={toggleScriptSelection}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -552,6 +881,8 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
|||||||
key={uniqueKey}
|
key={uniqueKey}
|
||||||
script={script}
|
script={script}
|
||||||
onClick={handleCardClick}
|
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
|
// Check if script files exist locally
|
||||||
checkScriptFiles: publicProcedure
|
checkScriptFiles: publicProcedure
|
||||||
.input(z.object({ slug: z.string() }))
|
.input(z.object({ slug: z.string() }))
|
||||||
|
|||||||
Reference in New Issue
Block a user