fix: ESLint/TypeScript fixes - nullish coalescing, regexp-exec, optional-chain, unescaped-entities, unused-vars, type-safety

This commit is contained in:
CanbiZ
2025-11-28 11:53:04 +01:00
parent f467b9ad7b
commit 9c759ba99b
18 changed files with 6229 additions and 3750 deletions

View File

@@ -1,8 +1,8 @@
'use client'; "use client";
import { Button } from './ui/button'; import { Button } from "./ui/button";
import { AlertTriangle } from 'lucide-react'; import { AlertTriangle } from "lucide-react";
import { useRegisterModal } from './modal/ModalStackProvider'; import { useRegisterModal } from "./modal/ModalStackProvider";
interface BackupWarningModalProps { interface BackupWarningModalProps {
isOpen: boolean; isOpen: boolean;
@@ -13,33 +13,43 @@ interface BackupWarningModalProps {
export function BackupWarningModal({ export function BackupWarningModal({
isOpen, isOpen,
onClose, onClose,
onProceed onProceed,
}: BackupWarningModalProps) { }: BackupWarningModalProps) {
useRegisterModal(isOpen, { id: 'backup-warning-modal', allowEscape: true, onClose }); useRegisterModal(isOpen, {
id: "backup-warning-modal",
allowEscape: true,
onClose,
});
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border"> <div className="bg-card border-border w-full max-w-md rounded-lg border shadow-xl">
{/* Header */} {/* Header */}
<div className="flex items-center justify-center p-6 border-b border-border"> <div className="border-border flex items-center justify-center border-b p-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<AlertTriangle className="h-8 w-8 text-warning" /> <AlertTriangle className="text-warning h-8 w-8" />
<h2 className="text-2xl font-bold text-card-foreground">Backup Failed</h2> <h2 className="text-card-foreground text-2xl font-bold">
Backup Failed
</h2>
</div> </div>
</div> </div>
{/* Content */} {/* Content */}
<div className="p-6"> <div className="p-6">
<p className="text-sm text-muted-foreground mb-6"> <p className="text-muted-foreground mb-6 text-sm">
The backup failed, but you can still proceed with the update if you wish. The backup failed, but you can still proceed with the update if you
<br /><br /> wish.
<strong className="text-foreground">Warning:</strong> Proceeding without a backup means you won't be able to restore the container if something goes wrong during the update. <br />
<br />
<strong className="text-foreground">Warning:</strong> Proceeding
without a backup means you won&apos;t be able to restore the
container if something goes wrong during the update.
</p> </p>
{/* Action Buttons */} {/* Action Buttons */}
<div className="flex flex-col sm:flex-row justify-end gap-3"> <div className="flex flex-col justify-end gap-3 sm:flex-row">
<Button <Button
onClick={onClose} onClick={onClose}
variant="outline" variant="outline"
@@ -52,7 +62,7 @@ export function BackupWarningModal({
onClick={onProceed} onClick={onProceed}
variant="default" variant="default"
size="default" size="default"
className="w-full sm:w-auto bg-warning hover:bg-warning/90" className="bg-warning hover:bg-warning/90 w-full sm:w-auto"
> >
Proceed Anyway Proceed Anyway
</Button> </Button>
@@ -62,6 +72,3 @@ export function BackupWarningModal({
</div> </div>
); );
} }

View File

@@ -1,18 +1,27 @@
'use client'; "use client";
import { useState, useEffect } from 'react'; import { useState, useEffect } from "react";
import { api } from '~/trpc/react'; import { api } from "~/trpc/react";
import { Button } from './ui/button'; import { Button } from "./ui/button";
import { Badge } from './ui/badge'; import { Badge } from "./ui/badge";
import { RefreshCw, ChevronDown, ChevronRight, HardDrive, Database, Server, CheckCircle, AlertCircle } from 'lucide-react'; import {
RefreshCw,
ChevronDown,
ChevronRight,
HardDrive,
Database,
Server,
CheckCircle,
AlertCircle,
} from "lucide-react";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from './ui/dropdown-menu'; } from "./ui/dropdown-menu";
import { ConfirmationModal } from './ConfirmationModal'; import { ConfirmationModal } from "./ConfirmationModal";
import { LoadingModal } from './LoadingModal'; import { LoadingModal } from "./LoadingModal";
interface Backup { interface Backup {
id: number; id: number;
@@ -35,16 +44,25 @@ interface ContainerBackups {
} }
export function BackupsTab() { export function BackupsTab() {
const [expandedContainers, setExpandedContainers] = useState<Set<string>>(new Set()); const [expandedContainers, setExpandedContainers] = useState<Set<string>>(
new Set(),
);
const [hasAutoDiscovered, setHasAutoDiscovered] = useState(false); const [hasAutoDiscovered, setHasAutoDiscovered] = useState(false);
const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false); const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false);
const [selectedBackup, setSelectedBackup] = useState<{ backup: Backup; containerId: string } | null>(null); const [selectedBackup, setSelectedBackup] = useState<{
backup: Backup;
containerId: string;
} | null>(null);
const [restoreProgress, setRestoreProgress] = useState<string[]>([]); const [restoreProgress, setRestoreProgress] = useState<string[]>([]);
const [restoreSuccess, setRestoreSuccess] = useState(false); const [restoreSuccess, setRestoreSuccess] = useState(false);
const [restoreError, setRestoreError] = useState<string | null>(null); const [restoreError, setRestoreError] = useState<string | null>(null);
const [shouldPollRestore, setShouldPollRestore] = useState(false); const [shouldPollRestore, setShouldPollRestore] = useState(false);
const { data: backupsData, refetch: refetchBackups, isLoading } = api.backups.getAllBackupsGrouped.useQuery(); const {
data: backupsData,
refetch: refetchBackups,
isLoading,
} = api.backups.getAllBackupsGrouped.useQuery();
const discoverMutation = api.backups.discoverBackups.useMutation({ const discoverMutation = api.backups.discoverBackups.useMutation({
onSuccess: () => { onSuccess: () => {
void refetchBackups(); void refetchBackups();
@@ -52,11 +70,14 @@ export function BackupsTab() {
}); });
// Poll for restore progress // Poll for restore progress
const { data: restoreLogsData } = api.backups.getRestoreProgress.useQuery(undefined, { const { data: restoreLogsData } = api.backups.getRestoreProgress.useQuery(
undefined,
{
enabled: shouldPollRestore, enabled: shouldPollRestore,
refetchInterval: 1000, // Poll every second refetchInterval: 1000, // Poll every second
refetchIntervalInBackground: true, refetchIntervalInBackground: true,
}); },
);
// Update restore progress when log data changes // Update restore progress when log data changes
useEffect(() => { useEffect(() => {
@@ -67,11 +88,12 @@ export function BackupsTab() {
if (restoreLogsData.isComplete) { if (restoreLogsData.isComplete) {
setShouldPollRestore(false); setShouldPollRestore(false);
// Check if restore was successful or failed // Check if restore was successful or failed
const lastLog = restoreLogsData.logs[restoreLogsData.logs.length - 1] || ''; const lastLog =
if (lastLog.includes('Restore completed successfully')) { restoreLogsData.logs[restoreLogsData.logs.length - 1] || "";
if (lastLog.includes("Restore completed successfully")) {
setRestoreSuccess(true); setRestoreSuccess(true);
setRestoreError(null); setRestoreError(null);
} else if (lastLog.includes('Error:') || lastLog.includes('failed')) { } else if (lastLog.includes("Error:") || lastLog.includes("failed")) {
setRestoreError(lastLog); setRestoreError(lastLog);
setRestoreSuccess(false); setRestoreSuccess(false);
} }
@@ -83,7 +105,7 @@ export function BackupsTab() {
onMutate: () => { onMutate: () => {
// Start polling for progress // Start polling for progress
setShouldPollRestore(true); setShouldPollRestore(true);
setRestoreProgress(['Starting restore...']); setRestoreProgress(["Starting restore..."]);
setRestoreError(null); setRestoreError(null);
setRestoreSuccess(false); setRestoreSuccess(false);
}, },
@@ -93,7 +115,12 @@ export function BackupsTab() {
if (result.success) { if (result.success) {
// Update progress with all messages from backend (fallback if polling didn't work) // Update progress with all messages from backend (fallback if polling didn't work)
const progressMessages = restoreProgress.length > 0 ? restoreProgress : (result.progress?.map(p => p.message) || ['Restore completed successfully']); const progressMessages =
restoreProgress.length > 0
? restoreProgress
: result.progress?.map((p) => p.message) || [
"Restore completed successfully",
];
setRestoreProgress(progressMessages); setRestoreProgress(progressMessages);
setRestoreSuccess(true); setRestoreSuccess(true);
setRestoreError(null); setRestoreError(null);
@@ -101,8 +128,10 @@ export function BackupsTab() {
setSelectedBackup(null); setSelectedBackup(null);
// Keep success message visible - user can dismiss manually // Keep success message visible - user can dismiss manually
} else { } else {
setRestoreError(result.error || 'Restore failed'); setRestoreError(result.error || "Restore failed");
setRestoreProgress(result.progress?.map(p => p.message) || restoreProgress); setRestoreProgress(
result.progress?.map((p) => p.message) || restoreProgress,
);
setRestoreSuccess(false); setRestoreSuccess(false);
setRestoreConfirmOpen(false); setRestoreConfirmOpen(false);
setSelectedBackup(null); setSelectedBackup(null);
@@ -112,7 +141,7 @@ export function BackupsTab() {
onError: (error) => { onError: (error) => {
// Stop polling on error // Stop polling on error
setShouldPollRestore(false); setShouldPollRestore(false);
setRestoreError(error.message || 'Restore failed'); setRestoreError(error.message || "Restore failed");
setRestoreConfirmOpen(false); setRestoreConfirmOpen(false);
setSelectedBackup(null); setSelectedBackup(null);
setRestoreProgress([]); setRestoreProgress([]);
@@ -120,9 +149,10 @@ export function BackupsTab() {
}); });
// Update progress text in modal based on current progress // Update progress text in modal based on current progress
const currentProgressText = restoreProgress.length > 0 const currentProgressText =
restoreProgress.length > 0
? restoreProgress[restoreProgress.length - 1] ? restoreProgress[restoreProgress.length - 1]
: 'Restoring backup...'; : "Restoring backup...";
// Auto-discover backups when tab is first opened // Auto-discover backups when tab is first opened
useEffect(() => { useEffect(() => {
@@ -172,39 +202,41 @@ export function BackupsTab() {
}; };
const formatFileSize = (bytes: bigint | null): string => { const formatFileSize = (bytes: bigint | null): string => {
if (!bytes) return 'Unknown size'; if (!bytes) return "Unknown size";
const b = Number(bytes); const b = Number(bytes);
if (b === 0) return '0 B'; if (b === 0) return "0 B";
const k = 1024; const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(b) / Math.log(k)); const i = Math.floor(Math.log(b) / Math.log(k));
return `${(b / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`; return `${(b / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
}; };
const formatDate = (date: Date | null): string => { const formatDate = (date: Date | null): string => {
if (!date) return 'Unknown date'; if (!date) return "Unknown date";
return new Date(date).toLocaleString(); return new Date(date).toLocaleString();
}; };
const getStorageTypeIcon = (type: string) => { const getStorageTypeIcon = (type: string) => {
switch (type) { switch (type) {
case 'pbs': case "pbs":
return <Database className="h-4 w-4" />; return <Database className="h-4 w-4" />;
case 'local': case "local":
return <HardDrive className="h-4 w-4" />; return <HardDrive className="h-4 w-4" />;
default: default:
return <Server className="h-4 w-4" />; return <Server className="h-4 w-4" />;
} }
}; };
const getStorageTypeBadgeVariant = (type: string): 'default' | 'secondary' | 'outline' => { const getStorageTypeBadgeVariant = (
type: string,
): "default" | "secondary" | "outline" => {
switch (type) { switch (type) {
case 'pbs': case "pbs":
return 'default'; return "default";
case 'local': case "local":
return 'secondary'; return "secondary";
default: default:
return 'outline'; return "outline";
} }
}; };
@@ -216,8 +248,8 @@ export function BackupsTab() {
{/* Header with refresh button */} {/* Header with refresh button */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h2 className="text-2xl font-bold text-foreground">Backups</h2> <h2 className="text-foreground text-2xl font-bold">Backups</h2>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-muted-foreground mt-1 text-sm">
Discovered backups grouped by container ID Discovered backups grouped by container ID
</p> </p>
</div> </div>
@@ -226,31 +258,38 @@ export function BackupsTab() {
disabled={isDiscovering} disabled={isDiscovering}
className="flex items-center gap-2" className="flex items-center gap-2"
> >
<RefreshCw className={`h-4 w-4 ${isDiscovering ? 'animate-spin' : ''}`} /> <RefreshCw
{isDiscovering ? 'Discovering...' : 'Discover Backups'} className={`h-4 w-4 ${isDiscovering ? "animate-spin" : ""}`}
/>
{isDiscovering ? "Discovering..." : "Discover Backups"}
</Button> </Button>
</div> </div>
{/* Loading state */} {/* Loading state */}
{(isLoading || isDiscovering) && backups.length === 0 && ( {(isLoading || isDiscovering) && backups.length === 0 && (
<div className="bg-card rounded-lg border border-border p-8 text-center"> <div className="bg-card border-border rounded-lg border p-8 text-center">
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-4 text-muted-foreground" /> <RefreshCw className="text-muted-foreground mx-auto mb-4 h-8 w-8 animate-spin" />
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{isDiscovering ? 'Discovering backups...' : 'Loading backups...'} {isDiscovering ? "Discovering backups..." : "Loading backups..."}
</p> </p>
</div> </div>
)} )}
{/* Empty state */} {/* Empty state */}
{!isLoading && !isDiscovering && backups.length === 0 && ( {!isLoading && !isDiscovering && backups.length === 0 && (
<div className="bg-card rounded-lg border border-border p-8 text-center"> <div className="bg-card border-border rounded-lg border p-8 text-center">
<HardDrive className="h-12 w-12 mx-auto mb-4 text-muted-foreground" /> <HardDrive className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
<h3 className="text-lg font-semibold text-foreground mb-2">No backups found</h3> <h3 className="text-foreground mb-2 text-lg font-semibold">
No backups found
</h3>
<p className="text-muted-foreground mb-4"> <p className="text-muted-foreground mb-4">
Click "Discover Backups" to scan for backups on your servers. Click &quot;Discover Backups&quot; to scan for backups on your
servers.
</p> </p>
<Button onClick={handleDiscoverBackups} disabled={isDiscovering}> <Button onClick={handleDiscoverBackups} disabled={isDiscovering}>
<RefreshCw className={`h-4 w-4 mr-2 ${isDiscovering ? 'animate-spin' : ''}`} /> <RefreshCw
className={`mr-2 h-4 w-4 ${isDiscovering ? "animate-spin" : ""}`}
/>
Discover Backups Discover Backups
</Button> </Button>
</div> </div>
@@ -266,33 +305,35 @@ export function BackupsTab() {
return ( return (
<div <div
key={container.container_id} key={container.container_id}
className="bg-card rounded-lg border border-border shadow-sm overflow-hidden" className="bg-card border-border overflow-hidden rounded-lg border shadow-sm"
> >
{/* Container header - collapsible */} {/* Container header - collapsible */}
<button <button
onClick={() => toggleContainer(container.container_id)} onClick={() => toggleContainer(container.container_id)}
className="w-full flex items-center justify-between p-4 hover:bg-accent/50 transition-colors text-left" className="hover:bg-accent/50 flex w-full items-center justify-between p-4 text-left transition-colors"
> >
<div className="flex items-center gap-3 flex-1 min-w-0"> <div className="flex min-w-0 flex-1 items-center gap-3">
{isExpanded ? ( {isExpanded ? (
<ChevronDown className="h-5 w-5 text-muted-foreground flex-shrink-0" /> <ChevronDown className="text-muted-foreground h-5 w-5 flex-shrink-0" />
) : ( ) : (
<ChevronRight className="h-5 w-5 text-muted-foreground flex-shrink-0" /> <ChevronRight className="text-muted-foreground h-5 w-5 flex-shrink-0" />
)} )}
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-foreground"> <span className="text-foreground font-semibold">
CT {container.container_id} CT {container.container_id}
</span> </span>
{container.hostname && ( {container.hostname && (
<> <>
<span className="text-muted-foreground"></span> <span className="text-muted-foreground"></span>
<span className="text-muted-foreground">{container.hostname}</span> <span className="text-muted-foreground">
{container.hostname}
</span>
</> </>
)} )}
</div> </div>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-muted-foreground mt-1 text-sm">
{backupCount} {backupCount === 1 ? 'backup' : 'backups'} {backupCount} {backupCount === 1 ? "backup" : "backups"}
</p> </p>
</div> </div>
</div> </div>
@@ -300,28 +341,30 @@ export function BackupsTab() {
{/* Container content - backups list */} {/* Container content - backups list */}
{isExpanded && ( {isExpanded && (
<div className="border-t border-border"> <div className="border-border border-t">
<div className="p-4 space-y-3"> <div className="space-y-3 p-4">
{container.backups.map((backup) => ( {container.backups.map((backup) => (
<div <div
key={backup.id} key={backup.id}
className="bg-muted/50 rounded-lg p-4 border border-border/50" className="bg-muted/50 border-border/50 rounded-lg border p-4"
> >
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-2 flex-wrap"> <div className="mb-2 flex flex-wrap items-center gap-2">
<span className="font-medium text-foreground break-all"> <span className="text-foreground font-medium break-all">
{backup.backup_name} {backup.backup_name}
</span> </span>
<Badge <Badge
variant={getStorageTypeBadgeVariant(backup.storage_type)} variant={getStorageTypeBadgeVariant(
backup.storage_type,
)}
className="flex items-center gap-1" className="flex items-center gap-1"
> >
{getStorageTypeIcon(backup.storage_type)} {getStorageTypeIcon(backup.storage_type)}
{backup.storage_name} {backup.storage_name}
</Badge> </Badge>
</div> </div>
<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground"> <div className="text-muted-foreground flex flex-wrap items-center gap-4 text-sm">
{backup.size && ( {backup.size && (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<HardDrive className="h-3 w-3" /> <HardDrive className="h-3 w-3" />
@@ -339,7 +382,7 @@ export function BackupsTab() {
)} )}
</div> </div>
<div className="mt-2"> <div className="mt-2">
<code className="text-xs text-muted-foreground break-all"> <code className="text-muted-foreground text-xs break-all">
{backup.backup_path} {backup.backup_path}
</code> </code>
</div> </div>
@@ -350,14 +393,19 @@ export function BackupsTab() {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="bg-muted/20 hover:bg-muted/30 border border-muted text-muted-foreground hover:text-foreground hover:border-muted-foreground transition-all duration-200 hover:scale-105 hover:shadow-md" className="bg-muted/20 hover:bg-muted/30 border-muted text-muted-foreground hover:text-foreground hover:border-muted-foreground border transition-all duration-200 hover:scale-105 hover:shadow-md"
> >
Actions Actions
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className="w-48 bg-card border-border"> <DropdownMenuContent className="bg-card border-border w-48">
<DropdownMenuItem <DropdownMenuItem
onClick={() => handleRestoreClick(backup, container.container_id)} onClick={() =>
handleRestoreClick(
backup,
container.container_id,
)
}
disabled={restoreMutation.isPending} disabled={restoreMutation.isPending}
className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20" className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20"
> >
@@ -386,9 +434,9 @@ export function BackupsTab() {
{/* Error state */} {/* Error state */}
{backupsData && !backupsData.success && ( {backupsData && !backupsData.success && (
<div className="bg-destructive/10 border border-destructive rounded-lg p-4"> <div className="bg-destructive/10 border-destructive rounded-lg border p-4">
<p className="text-destructive"> <p className="text-destructive">
Error loading backups: {backupsData.error || 'Unknown error'} Error loading backups: {backupsData.error || "Unknown error"}
</p> </p>
</div> </div>
)} )}
@@ -412,7 +460,8 @@ export function BackupsTab() {
)} )}
{/* Restore Progress Modal */} {/* Restore Progress Modal */}
{(restoreMutation.isPending || (restoreSuccess && restoreProgress.length > 0)) && ( {(restoreMutation.isPending ||
(restoreSuccess && restoreProgress.length > 0)) && (
<LoadingModal <LoadingModal
isOpen={true} isOpen={true}
action={currentProgressText} action={currentProgressText}
@@ -428,11 +477,13 @@ export function BackupsTab() {
{/* Restore Success */} {/* Restore Success */}
{restoreSuccess && ( {restoreSuccess && (
<div className="bg-success/10 border border-success/20 rounded-lg p-4"> <div className="bg-success/10 border-success/20 rounded-lg border p-4">
<div className="flex items-center justify-between mb-2"> <div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-success" /> <CheckCircle className="text-success h-5 w-5" />
<span className="font-medium text-success">Restore Completed Successfully</span> <span className="text-success font-medium">
Restore Completed Successfully
</span>
</div> </div>
<Button <Button
variant="ghost" variant="ghost"
@@ -446,7 +497,7 @@ export function BackupsTab() {
× ×
</Button> </Button>
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-muted-foreground text-sm">
The container has been restored from backup. The container has been restored from backup.
</p> </p>
</div> </div>
@@ -454,11 +505,11 @@ export function BackupsTab() {
{/* Restore Error */} {/* Restore Error */}
{restoreError && ( {restoreError && (
<div className="bg-error/10 border border-error/20 rounded-lg p-4"> <div className="bg-error/10 border-error/20 rounded-lg border p-4">
<div className="flex items-center justify-between mb-2"> <div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-error" /> <AlertCircle className="text-error h-5 w-5" />
<span className="font-medium text-error">Restore Failed</span> <span className="text-error font-medium">Restore Failed</span>
</div> </div>
<Button <Button
variant="ghost" variant="ghost"
@@ -472,13 +523,11 @@ export function BackupsTab() {
× ×
</Button> </Button>
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-muted-foreground text-sm">{restoreError}</p>
{restoreError}
</p>
{restoreProgress.length > 0 && ( {restoreProgress.length > 0 && (
<div className="space-y-1 mt-2"> <div className="mt-2 space-y-1">
{restoreProgress.map((message, index) => ( {restoreProgress.map((message, index) => (
<p key={index} className="text-sm text-muted-foreground"> <p key={index} className="text-muted-foreground text-sm">
{message} {message}
</p> </p>
))} ))}
@@ -500,4 +549,3 @@ export function BackupsTab() {
</div> </div>
); );
} }

View File

@@ -1,41 +1,53 @@
'use client'; "use client";
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from "react";
import { api } from '~/trpc/react'; import { api } from "~/trpc/react";
import { ScriptCard } from './ScriptCard'; import { ScriptCard } from "./ScriptCard";
import { ScriptCardList } from './ScriptCardList'; import { ScriptCardList } from "./ScriptCardList";
import { ScriptDetailModal } from './ScriptDetailModal'; import { ScriptDetailModal } from "./ScriptDetailModal";
import { CategorySidebar } from './CategorySidebar'; import { CategorySidebar } from "./CategorySidebar";
import { FilterBar, type FilterState } from './FilterBar'; import { FilterBar, type FilterState } from "./FilterBar";
import { ViewToggle } from './ViewToggle'; import { ViewToggle } from "./ViewToggle";
import { Button } from './ui/button'; import { Button } from "./ui/button";
import type { ScriptCard as ScriptCardType } from '~/types/script'; import type { ScriptCard as ScriptCardType } from "~/types/script";
import { getDefaultFilters, mergeFiltersWithDefaults } from './filterUtils'; import type { Server } from "~/types/server";
import { getDefaultFilters, mergeFiltersWithDefaults } from "./filterUtils";
interface DownloadedScriptsTabProps { interface DownloadedScriptsTabProps {
onInstallScript?: ( onInstallScript?: (
scriptPath: string, scriptPath: string,
scriptName: string, scriptName: string,
mode?: "local" | "ssh", mode?: "local" | "ssh",
server?: any, server?: Server,
) => void; ) => void;
} }
export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabProps) { export function DownloadedScriptsTab({
onInstallScript,
}: DownloadedScriptsTabProps) {
const [selectedSlug, setSelectedSlug] = useState<string | null>(null); const [selectedSlug, setSelectedSlug] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
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 [filters, setFilters] = useState<FilterState>(getDefaultFilters()); const [filters, setFilters] = useState<FilterState>(getDefaultFilters());
const [saveFiltersEnabled, setSaveFiltersEnabled] = useState(false); const [saveFiltersEnabled, setSaveFiltersEnabled] = useState(false);
const [isLoadingFilters, setIsLoadingFilters] = useState(true); const [isLoadingFilters, setIsLoadingFilters] = useState(true);
const gridRef = useRef<HTMLDivElement>(null); const gridRef = useRef<HTMLDivElement>(null);
const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery(); const {
const { data: localScriptsData, isLoading: localLoading, error: localError } = api.scripts.getAllDownloadedScripts.useQuery(); 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( const { data: scriptData } = api.scripts.getScriptBySlug.useQuery(
{ slug: selectedSlug ?? '' }, { slug: selectedSlug ?? "" },
{ enabled: !!selectedSlug } { enabled: !!selectedSlug },
); );
// Load SAVE_FILTER setting, saved filters, and view mode on component mount // Load SAVE_FILTER setting, saved filters, and view mode on component mount
@@ -43,7 +55,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
const loadSettings = async () => { const loadSettings = async () => {
try { try {
// Load SAVE_FILTER setting // Load SAVE_FILTER setting
const saveFilterResponse = await fetch('/api/settings/save-filter'); const saveFilterResponse = await fetch("/api/settings/save-filter");
let saveFilterEnabled = false; let saveFilterEnabled = false;
if (saveFilterResponse.ok) { if (saveFilterResponse.ok) {
const saveFilterData = await saveFilterResponse.json(); const saveFilterData = await saveFilterResponse.json();
@@ -53,7 +65,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
// Load saved filters if SAVE_FILTER is enabled // Load saved filters if SAVE_FILTER is enabled
if (saveFilterEnabled) { if (saveFilterEnabled) {
const filtersResponse = await fetch('/api/settings/filters'); const filtersResponse = await fetch("/api/settings/filters");
if (filtersResponse.ok) { if (filtersResponse.ok) {
const filtersData = await filtersResponse.json(); const filtersData = await filtersResponse.json();
if (filtersData.filters) { if (filtersData.filters) {
@@ -63,16 +75,20 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
} }
// Load view mode // Load view mode
const viewModeResponse = await fetch('/api/settings/view-mode'); const viewModeResponse = await fetch("/api/settings/view-mode");
if (viewModeResponse.ok) { if (viewModeResponse.ok) {
const viewModeData = await viewModeResponse.json(); const viewModeData = await viewModeResponse.json();
const viewMode = viewModeData.viewMode; const viewMode = viewModeData.viewMode;
if (viewMode && typeof viewMode === 'string' && (viewMode === 'card' || viewMode === 'list')) { if (
viewMode &&
typeof viewMode === "string" &&
(viewMode === "card" || viewMode === "list")
) {
setViewMode(viewMode); setViewMode(viewMode);
} }
} }
} catch (error) { } catch (error) {
console.error('Error loading settings:', error); console.error("Error loading settings:", error);
} finally { } finally {
setIsLoadingFilters(false); setIsLoadingFilters(false);
} }
@@ -87,15 +103,15 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
const saveFilters = async () => { const saveFilters = async () => {
try { try {
await fetch('/api/settings/filters', { await fetch("/api/settings/filters", {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
}, },
body: JSON.stringify({ filters }), body: JSON.stringify({ filters }),
}); });
} catch (error) { } catch (error) {
console.error('Error saving filters:', error); console.error("Error saving filters:", error);
} }
}; };
@@ -110,15 +126,15 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
const saveViewMode = async () => { const saveViewMode = async () => {
try { try {
await fetch('/api/settings/view-mode', { await fetch("/api/settings/view-mode", {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
}, },
body: JSON.stringify({ viewMode }), body: JSON.stringify({ viewMode }),
}); });
} catch (error) { } catch (error) {
console.error('Error saving view mode:', error); console.error("Error saving view mode:", error);
} }
}; };
@@ -129,13 +145,14 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
// Extract categories from metadata // Extract categories from metadata
const categories = React.useMemo((): string[] => { 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[]) return (scriptCardsData.metadata.categories as any[])
.filter((cat) => cat.id !== 0) // Exclude Miscellaneous for main list .filter((cat) => cat.id !== 0) // Exclude Miscellaneous for main list
.sort((a, b) => a.sort_order - b.sort_order) .sort((a, b) => a.sort_order - b.sort_order)
.map((cat) => cat.name as string) .map((cat) => cat.name as string)
.filter((name): name is string => typeof name === 'string'); .filter((name): name is string => typeof name === "string");
}, [scriptCardsData]); }, [scriptCardsData]);
// Get GitHub scripts with download status (deduplicated) // Get GitHub scripts with download status (deduplicated)
@@ -145,13 +162,13 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
// Use Map to deduplicate by slug/name // Use Map to deduplicate by slug/name
const scriptMap = new Map<string, ScriptCardType>(); const scriptMap = new Map<string, ScriptCardType>();
scriptCardsData.cards?.forEach(script => { scriptCardsData.cards?.forEach((script) => {
if (script?.name && script?.slug) { if (script?.name && script?.slug) {
// Use slug as unique identifier, only keep first occurrence // Use slug as unique identifier, only keep first occurrence
if (!scriptMap.has(script.slug)) { if (!scriptMap.has(script.slug)) {
scriptMap.set(script.slug, { scriptMap.set(script.slug, {
...script, ...script,
source: 'github' as const, source: "github" as const,
isDownloaded: false, // Will be updated by status check isDownloaded: false, // Will be updated by status check
isUpToDate: false, // Will be updated by status check isUpToDate: false, // Will be updated by status check
}); });
@@ -165,20 +182,22 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
// Update scripts with download status and filter to only downloaded scripts // Update scripts with download status and filter to only downloaded scripts
const downloadedScripts = React.useMemo((): ScriptCardType[] => { const downloadedScripts = React.useMemo((): ScriptCardType[] => {
// Helper to normalize identifiers so underscores vs hyphens don't break matches // Helper to normalize identifiers so underscores vs hyphens don't break matches
const normalizeId = (s?: string): string => (s ?? '') const normalizeId = (s?: string): string =>
(s ?? "")
.toLowerCase() .toLowerCase()
.replace(/\.(sh|bash|py|js|ts)$/g, '') .replace(/\.(sh|bash|py|js|ts)$/g, "")
.replace(/[^a-z0-9]+/g, '-') .replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, ''); .replace(/^-+|-+$/g, "");
return combinedScripts return combinedScripts
.map(script => { .map((script) => {
if (!script?.name) { if (!script?.name) {
return script; // Return as-is if invalid return script; // Return as-is if invalid
} }
// Check if there's a corresponding local script // Check if there's a corresponding local script
const hasLocalVersion = localScriptsData?.scripts?.some(local => { const hasLocalVersion =
localScriptsData?.scripts?.some((local) => {
if (!local?.name) return false; if (!local?.name) return false;
// Primary: Exact slug-to-slug matching (most reliable, prevents false positives) // Primary: Exact slug-to-slug matching (most reliable, prevents false positives)
@@ -191,7 +210,10 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
// Secondary: Check install basenames (for edge cases where install script names differ from slugs) // 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 // Only use normalized matching for install basenames, not for slug/name matching
const normalizedLocal = normalizeId(local.name); const normalizedLocal = normalizeId(local.name);
const matchesInstallBasename = (script as any)?.install_basenames?.some((base: string) => normalizeId(base) === normalizedLocal) ?? false; const matchesInstallBasename =
(script as any)?.install_basenames?.some(
(base: string) => normalizeId(base) === normalizedLocal,
) ?? false;
return matchesInstallBasename; return matchesInstallBasename;
}) ?? false; }) ?? false;
@@ -200,7 +222,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
isDownloaded: hasLocalVersion, isDownloaded: hasLocalVersion,
}; };
}) })
.filter(script => script.isDownloaded); // Only show downloaded scripts .filter((script) => script.isDownloaded); // Only show downloaded scripts
}, [combinedScripts, localScriptsData]); }, [combinedScripts, localScriptsData]);
// Count scripts per category (using downloaded scripts only) // Count scripts per category (using downloaded scripts only)
@@ -215,11 +237,15 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
}); });
// Count each unique downloaded script only once per category // Count each unique downloaded script only once per category
downloadedScripts.forEach(script => { downloadedScripts.forEach((script) => {
if (script.categoryNames && script.slug) { if (script.categoryNames && script.slug) {
const countedCategories = new Set<string>(); const countedCategories = new Set<string>();
script.categoryNames.forEach((categoryName: unknown) => { 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); countedCategories.add(categoryName);
counts[categoryName]++; counts[categoryName]++;
} }
@@ -239,13 +265,13 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
const query = filters.searchQuery.toLowerCase().trim(); const query = filters.searchQuery.toLowerCase().trim();
if (query.length >= 1) { if (query.length >= 1) {
scripts = scripts.filter(script => { scripts = scripts.filter((script) => {
if (!script || typeof script !== 'object') { if (!script || typeof script !== "object") {
return false; return false;
} }
const name = (script.name ?? '').toLowerCase(); const name = (script.name ?? "").toLowerCase();
const slug = (script.slug ?? '').toLowerCase(); const slug = (script.slug ?? "").toLowerCase();
return name.includes(query) ?? slug.includes(query); return name.includes(query) ?? slug.includes(query);
}); });
@@ -254,7 +280,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
// Filter by category using real category data from downloaded scripts // Filter by category using real category data from downloaded scripts
if (selectedCategory) { if (selectedCategory) {
scripts = scripts.filter(script => { scripts = scripts.filter((script) => {
if (!script) return false; if (!script) return false;
// Check if the downloaded script has categoryNames that include the selected category // Check if the downloaded script has categoryNames that include the selected category
@@ -264,7 +290,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
// Filter by updateable status // Filter by updateable status
if (filters.showUpdatable !== null) { if (filters.showUpdatable !== null) {
scripts = scripts.filter(script => { scripts = scripts.filter((script) => {
if (!script) return false; if (!script) return false;
const isUpdatable = script.updateable ?? false; const isUpdatable = script.updateable ?? false;
return filters.showUpdatable ? isUpdatable : !isUpdatable; return filters.showUpdatable ? isUpdatable : !isUpdatable;
@@ -273,20 +299,22 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
// Filter by script types // Filter by script types
if (filters.selectedTypes.length > 0) { if (filters.selectedTypes.length > 0) {
scripts = scripts.filter(script => { scripts = scripts.filter((script) => {
if (!script) return false; if (!script) return false;
const scriptType = (script.type ?? '').toLowerCase(); const scriptType = (script.type ?? "").toLowerCase();
// Map non-standard types to standard categories // Map non-standard types to standard categories
const mappedType = scriptType === 'turnkey' ? 'ct' : scriptType; const mappedType = scriptType === "turnkey" ? "ct" : scriptType;
return filters.selectedTypes.some(type => type.toLowerCase() === mappedType); return filters.selectedTypes.some(
(type) => type.toLowerCase() === mappedType,
);
}); });
} }
// Filter by repositories // Filter by repositories
if (filters.selectedRepositories.length > 0) { if (filters.selectedRepositories.length > 0) {
scripts = scripts.filter(script => { scripts = scripts.filter((script) => {
if (!script) return false; if (!script) return false;
const repoUrl = script.repository_url; const repoUrl = script.repository_url;
@@ -307,13 +335,13 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
let compareValue = 0; let compareValue = 0;
switch (filters.sortBy) { switch (filters.sortBy) {
case 'name': case "name":
compareValue = (a.name ?? '').localeCompare(b.name ?? ''); compareValue = (a.name ?? "").localeCompare(b.name ?? "");
break; break;
case 'created': case "created":
// Get creation date from script metadata in JSON format (date_created: "YYYY-MM-DD") // Get creation date from script metadata in JSON format (date_created: "YYYY-MM-DD")
const aCreated = a?.date_created ?? ''; const aCreated = a?.date_created ?? "";
const bCreated = b?.date_created ?? ''; const bCreated = b?.date_created ?? "";
// If both have dates, compare them directly // If both have dates, compare them directly
if (aCreated && bCreated) { if (aCreated && bCreated) {
@@ -327,15 +355,15 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
compareValue = 1; compareValue = 1;
} else { } else {
// Both have no dates, fallback to name comparison // Both have no dates, fallback to name comparison
compareValue = (a.name ?? '').localeCompare(b.name ?? ''); compareValue = (a.name ?? "").localeCompare(b.name ?? "");
} }
break; break;
default: default:
compareValue = (a.name ?? '').localeCompare(b.name ?? ''); compareValue = (a.name ?? "").localeCompare(b.name ?? "");
} }
// Apply sort order // Apply sort order
return filters.sortOrder === 'asc' ? compareValue : -compareValue; return filters.sortOrder === "asc" ? compareValue : -compareValue;
}); });
return scripts; return scripts;
@@ -343,7 +371,9 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
// Calculate filter counts for FilterBar // Calculate filter counts for FilterBar
const filterCounts = React.useMemo(() => { const filterCounts = React.useMemo(() => {
const updatableCount = downloadedScripts.filter(script => script?.updateable).length; const updatableCount = downloadedScripts.filter(
(script) => script?.updateable,
).length;
return { installedCount: downloadedScripts.length, updatableCount }; return { installedCount: downloadedScripts.length, updatableCount };
}, [downloadedScripts]); }, [downloadedScripts]);
@@ -363,9 +393,9 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
if (selectedCategory && gridRef.current) { if (selectedCategory && gridRef.current) {
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
gridRef.current?.scrollIntoView({ gridRef.current?.scrollIntoView({
behavior: 'smooth', behavior: "smooth",
block: 'start', block: "start",
inline: 'nearest' inline: "nearest",
}); });
}, 100); }, 100);
@@ -387,22 +417,38 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
if (githubLoading || localLoading) { if (githubLoading || localLoading) {
return ( return (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div> <div className="border-primary h-8 w-8 animate-spin rounded-full border-b-2"></div>
<span className="ml-2 text-muted-foreground">Loading downloaded scripts...</span> <span className="text-muted-foreground ml-2">
Loading downloaded scripts...
</span>
</div> </div>
); );
} }
if (githubError || localError) { if (githubError || localError) {
return ( return (
<div className="text-center py-12"> <div className="py-12 text-center">
<div className="text-error mb-4"> <div className="text-error mb-4">
<svg className="w-12 h-12 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" /> className="mx-auto mb-2 h-12 w-12"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg> </svg>
<p className="text-lg font-medium">Failed to load downloaded scripts</p> <p className="text-lg font-medium">
<p className="text-sm text-muted-foreground mt-1"> Failed to load downloaded scripts
{githubError?.message ?? localError?.message ?? 'Unknown error occurred'} </p>
<p className="text-muted-foreground mt-1 text-sm">
{githubError?.message ??
localError?.message ??
"Unknown error occurred"}
</p> </p>
</div> </div>
<Button <Button
@@ -419,14 +465,25 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
if (!downloadedScripts?.length) { if (!downloadedScripts?.length) {
return ( return (
<div className="text-center py-12"> <div className="py-12 text-center">
<div className="text-muted-foreground"> <div className="text-muted-foreground">
<svg className="w-12 h-12 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> className="mx-auto mb-4 h-12 w-12"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg> </svg>
<p className="text-lg font-medium">No downloaded scripts found</p> <p className="text-lg font-medium">No downloaded scripts found</p>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-muted-foreground mt-1 text-sm">
You haven&apos;t downloaded any scripts yet. Visit the Available Scripts tab to download some scripts. You haven&apos;t downloaded any scripts yet. Visit the Available
Scripts tab to download some scripts.
</p> </p>
</div> </div>
</div> </div>
@@ -435,12 +492,9 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:gap-6">
<div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
{/* Category Sidebar */} {/* Category Sidebar */}
<div className="flex-shrink-0 order-2 lg:order-1"> <div className="order-2 flex-shrink-0 lg:order-1">
<CategorySidebar <CategorySidebar
categories={categories} categories={categories}
categoryCounts={categoryCounts} categoryCounts={categoryCounts}
@@ -451,7 +505,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
</div> </div>
{/* Main Content */} {/* Main Content */}
<div className="flex-1 min-w-0 order-1 lg:order-2" ref={gridRef}> <div className="order-1 min-w-0 flex-1 lg:order-2" ref={gridRef}>
{/* Enhanced Filter Bar */} {/* Enhanced Filter Bar */}
<FilterBar <FilterBar
filters={filters} filters={filters}
@@ -464,26 +518,41 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
/> />
{/* View Toggle */} {/* View Toggle */}
<ViewToggle <ViewToggle viewMode={viewMode} onViewModeChange={setViewMode} />
viewMode={viewMode}
onViewModeChange={setViewMode}
/>
{/* Scripts Grid */} {/* Scripts Grid */}
{filteredScripts.length === 0 && (filters.searchQuery || selectedCategory || filters.showUpdatable !== null || filters.selectedTypes.length > 0) ? ( {filteredScripts.length === 0 &&
<div className="text-center py-12"> (filters.searchQuery ||
selectedCategory ||
filters.showUpdatable !== null ||
filters.selectedTypes.length > 0) ? (
<div className="py-12 text-center">
<div className="text-muted-foreground"> <div className="text-muted-foreground">
<svg className="w-12 h-12 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> className="mx-auto mb-4 h-12 w-12"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg> </svg>
<p className="text-lg font-medium">No matching downloaded scripts found</p> <p className="text-lg font-medium">
<p className="text-sm text-muted-foreground mt-1"> No matching downloaded scripts found
</p>
<p className="text-muted-foreground mt-1 text-sm">
Try different filter settings or clear all filters. Try different filter settings or clear all filters.
</p> </p>
<div className="flex justify-center gap-2 mt-4"> <div className="mt-4 flex justify-center gap-2">
{filters.searchQuery && ( {filters.searchQuery && (
<Button <Button
onClick={() => handleFiltersChange({ ...filters, searchQuery: '' })} onClick={() =>
handleFiltersChange({ ...filters, searchQuery: "" })
}
variant="default" variant="default"
size="default" size="default"
> >
@@ -502,17 +571,16 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
</div> </div>
</div> </div>
</div> </div>
) : ( ) : viewMode === "card" ? (
viewMode === 'card' ? ( <div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{filteredScripts.map((script, index) => { {filteredScripts.map((script, index) => {
// Add validation to ensure script has required properties // Add validation to ensure script has required properties
if (!script || typeof script !== 'object') { if (!script || typeof script !== "object") {
return null; return null;
} }
// Create a unique key by combining slug, name, and index to handle duplicates // 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 ( return (
<ScriptCard <ScriptCard
@@ -527,12 +595,12 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
<div className="space-y-3"> <div className="space-y-3">
{filteredScripts.map((script, index) => { {filteredScripts.map((script, index) => {
// Add validation to ensure script has required properties // Add validation to ensure script has required properties
if (!script || typeof script !== 'object') { if (!script || typeof script !== "object") {
return null; return null;
} }
// Create a unique key by combining slug, name, and index to handle duplicates // 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 ( return (
<ScriptCardList <ScriptCardList
@@ -543,7 +611,6 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
); );
})} })}
</div> </div>
)
)} )}
<ScriptDetailModal <ScriptDetailModal

View File

@@ -3,7 +3,17 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { ContextualHelpIcon } from "./ContextualHelpIcon"; import { ContextualHelpIcon } from "./ContextualHelpIcon";
import { Package, Monitor, Wrench, Server, FileText, Calendar, RefreshCw, Filter, GitBranch } from "lucide-react"; import {
Package,
Monitor,
Wrench,
Server,
FileText,
Calendar,
RefreshCw,
Filter,
GitBranch,
} from "lucide-react";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { getDefaultFilters } from "./filterUtils"; import { getDefaultFilters } from "./filterUtils";
@@ -53,7 +63,7 @@ export function FilterBar({
// Helper function to extract repository name from URL // Helper function to extract repository name from URL
const getRepoName = (url: string): string => { const getRepoName = (url: string): string => {
try { try {
const match = url.match(/github\.com\/([^\/]+)\/([^\/]+)/); const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(url);
if (match) { if (match) {
return `${match[1]}/${match[2]}`; return `${match[1]}/${match[2]}`;
} }
@@ -98,29 +108,33 @@ export function FilterBar({
}; };
return ( return (
<div className="mb-6 rounded-lg border border-border bg-card p-4 sm:p-6 shadow-sm"> <div className="border-border bg-card mb-6 rounded-lg border p-4 shadow-sm sm:p-6">
{/* Loading State */} {/* Loading State */}
{isLoadingFilters && ( {isLoadingFilters && (
<div className="mb-4 flex items-center justify-center py-2"> <div className="mb-4 flex items-center justify-center py-2">
<div className="flex items-center space-x-2 text-sm text-muted-foreground"> <div className="text-muted-foreground flex items-center space-x-2 text-sm">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div> <div className="border-primary h-4 w-4 animate-spin rounded-full border-2 border-t-transparent"></div>
<span>Loading saved filters...</span> <span>Loading saved filters...</span>
</div> </div>
</div> </div>
)} )}
{/* Filter Header */} {/* Filter Header */}
{!isLoadingFilters && ( {!isLoadingFilters && (
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-medium text-foreground">Filter Scripts</h3> <h3 className="text-foreground text-lg font-medium">
Filter Scripts
</h3>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ContextualHelpIcon section="available-scripts" tooltip="Help with filtering and searching" /> <ContextualHelpIcon
section="available-scripts"
tooltip="Help with filtering and searching"
/>
<Button <Button
onClick={() => setIsMinimized(!isMinimized)} onClick={() => setIsMinimized(!isMinimized)}
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground" className="text-muted-foreground hover:text-foreground h-8 w-8"
title={isMinimized ? "Expand filters" : "Minimize filters"} title={isMinimized ? "Expand filters" : "Minimize filters"}
> >
<svg <svg
@@ -146,10 +160,10 @@ export function FilterBar({
<> <>
{/* Search Bar */} {/* Search Bar */}
<div className="mb-4"> <div className="mb-4">
<div className="relative max-w-md w-full"> <div className="relative w-full max-w-md">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3"> <div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<svg <svg
className="h-5 w-5 text-muted-foreground" className="text-muted-foreground h-5 w-5"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -167,13 +181,13 @@ export function FilterBar({
placeholder="Search scripts..." placeholder="Search scripts..."
value={filters.searchQuery} value={filters.searchQuery}
onChange={(e) => updateFilters({ searchQuery: e.target.value })} onChange={(e) => updateFilters({ searchQuery: e.target.value })}
className="block w-full rounded-lg border border-input bg-background py-3 pr-10 pl-10 text-sm leading-5 text-foreground placeholder-muted-foreground focus:border-primary focus:placeholder-muted-foreground focus:ring-2 focus:ring-primary focus:outline-none" className="border-input bg-background text-foreground placeholder-muted-foreground focus:border-primary focus:placeholder-muted-foreground focus:ring-primary block w-full rounded-lg border py-3 pr-10 pl-10 text-sm leading-5 focus:ring-2 focus:outline-none"
/> />
{filters.searchQuery && ( {filters.searchQuery && (
<Button <Button
onClick={() => updateFilters({ searchQuery: "" })} onClick={() => updateFilters({ searchQuery: "" })}
variant="ghost" variant="ghost"
className="absolute inset-y-0 right-0 flex items-center justify-center pr-3 h-full text-muted-foreground hover:text-foreground" className="text-muted-foreground hover:text-foreground absolute inset-y-0 right-0 flex h-full items-center justify-center pr-3"
> >
<svg <svg
className="h-5 w-5" className="h-5 w-5"
@@ -194,7 +208,7 @@ export function FilterBar({
</div> </div>
{/* Filter Buttons */} {/* Filter Buttons */}
<div className="mb-4 flex flex-col sm:flex-row flex-wrap gap-2 sm:gap-3"> <div className="mb-4 flex flex-col flex-wrap gap-2 sm:flex-row sm:gap-3">
{/* Updateable Filter */} {/* Updateable Filter */}
<Button <Button
onClick={() => { onClick={() => {
@@ -208,12 +222,12 @@ export function FilterBar({
}} }}
variant="outline" variant="outline"
size="default" size="default"
className={`w-full sm:w-auto flex items-center justify-center space-x-2 ${ className={`flex w-full items-center justify-center space-x-2 sm:w-auto ${
filters.showUpdatable === null filters.showUpdatable === null
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground" ? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
: filters.showUpdatable === true : filters.showUpdatable === true
? "border border-success/20 bg-success/10 text-success" ? "border-success/20 bg-success/10 text-success border"
: "border border-destructive/20 bg-destructive/10 text-destructive" : "border-destructive/20 bg-destructive/10 text-destructive border"
}`} }`}
> >
<RefreshCw className="h-4 w-4" /> <RefreshCw className="h-4 w-4" />
@@ -226,10 +240,10 @@ export function FilterBar({
onClick={() => setIsTypeDropdownOpen(!isTypeDropdownOpen)} onClick={() => setIsTypeDropdownOpen(!isTypeDropdownOpen)}
variant="outline" variant="outline"
size="default" size="default"
className={`w-full flex items-center justify-center space-x-2 ${ className={`flex w-full items-center justify-center space-x-2 ${
filters.selectedTypes.length === 0 filters.selectedTypes.length === 0
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground" ? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
: "border border-primary/20 bg-primary/10 text-primary" : "border-primary/20 bg-primary/10 text-primary border"
}`} }`}
> >
<Filter className="h-4 w-4" /> <Filter className="h-4 w-4" />
@@ -250,14 +264,14 @@ export function FilterBar({
</Button> </Button>
{isTypeDropdownOpen && ( {isTypeDropdownOpen && (
<div className="absolute top-full left-0 z-10 mt-1 w-48 rounded-lg border border-border bg-card shadow-lg"> <div className="border-border bg-card absolute top-full left-0 z-10 mt-1 w-48 rounded-lg border shadow-lg">
<div className="p-2"> <div className="p-2">
{SCRIPT_TYPES.map((type) => { {SCRIPT_TYPES.map((type) => {
const IconComponent = type.Icon; const IconComponent = type.Icon;
return ( return (
<label <label
key={type.value} key={type.value}
className="flex cursor-pointer items-center space-x-3 rounded-md px-3 py-2 hover:bg-accent" className="hover:bg-accent flex cursor-pointer items-center space-x-3 rounded-md px-3 py-2"
> >
<input <input
type="checkbox" type="checkbox"
@@ -278,17 +292,17 @@ export function FilterBar({
}); });
} }
}} }}
className="rounded border-input text-primary focus:ring-primary" className="border-input text-primary focus:ring-primary rounded"
/> />
<IconComponent className="h-4 w-4" /> <IconComponent className="h-4 w-4" />
<span className="text-sm text-muted-foreground"> <span className="text-muted-foreground text-sm">
{type.label} {type.label}
</span> </span>
</label> </label>
); );
})} })}
</div> </div>
<div className="border-t border-border p-2"> <div className="border-border border-t p-2">
<Button <Button
onClick={() => { onClick={() => {
updateFilters({ selectedTypes: [] }); updateFilters({ selectedTypes: [] });
@@ -296,7 +310,7 @@ export function FilterBar({
}} }}
variant="ghost" variant="ghost"
size="sm" size="sm"
className="w-full justify-start text-muted-foreground hover:bg-accent hover:text-foreground" className="text-muted-foreground hover:bg-accent hover:text-foreground w-full justify-start"
> >
Clear all Clear all
</Button> </Button>
@@ -306,8 +320,11 @@ export function FilterBar({
</div> </div>
{/* Repository Filter Buttons - Only show if more than one enabled repo */} {/* Repository Filter Buttons - Only show if more than one enabled repo */}
{enabledRepos.length > 1 && enabledRepos.map((repo) => { {enabledRepos.length > 1 &&
const isSelected = filters.selectedRepositories.includes(repo.url); enabledRepos.map((repo) => {
const isSelected = filters.selectedRepositories.includes(
repo.url,
);
return ( return (
<Button <Button
key={repo.id} key={repo.id}
@@ -316,20 +333,22 @@ export function FilterBar({
if (isSelected) { if (isSelected) {
// Remove repository from selection // Remove repository from selection
updateFilters({ updateFilters({
selectedRepositories: currentSelected.filter(url => url !== repo.url) selectedRepositories: currentSelected.filter(
(url) => url !== repo.url,
),
}); });
} else { } else {
// Add repository to selection // Add repository to selection
updateFilters({ updateFilters({
selectedRepositories: [...currentSelected, repo.url] selectedRepositories: [...currentSelected, repo.url],
}); });
} }
}} }}
variant="outline" variant="outline"
size="default" size="default"
className={`w-full sm:w-auto flex items-center justify-center space-x-2 ${ className={`flex w-full items-center justify-center space-x-2 sm:w-auto ${
isSelected isSelected
? "border border-primary/20 bg-primary/10 text-primary" ? "border-primary/20 bg-primary/10 text-primary border"
: "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground" : "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
}`} }`}
> >
@@ -345,14 +364,16 @@ export function FilterBar({
onClick={() => setIsSortDropdownOpen(!isSortDropdownOpen)} onClick={() => setIsSortDropdownOpen(!isSortDropdownOpen)}
variant="outline" variant="outline"
size="default" size="default"
className="w-full sm:w-auto flex items-center justify-center space-x-2 bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground" className="bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground flex w-full items-center justify-center space-x-2 sm:w-auto"
> >
{filters.sortBy === "name" ? ( {filters.sortBy === "name" ? (
<FileText className="h-4 w-4" /> <FileText className="h-4 w-4" />
) : ( ) : (
<Calendar className="h-4 w-4" /> <Calendar className="h-4 w-4" />
)} )}
<span>{filters.sortBy === "name" ? "By Name" : "By Created Date"}</span> <span>
{filters.sortBy === "name" ? "By Name" : "By Created Date"}
</span>
<svg <svg
className={`h-4 w-4 transition-transform ${isSortDropdownOpen ? "rotate-180" : ""}`} className={`h-4 w-4 transition-transform ${isSortDropdownOpen ? "rotate-180" : ""}`}
fill="none" fill="none"
@@ -369,15 +390,17 @@ export function FilterBar({
</Button> </Button>
{isSortDropdownOpen && ( {isSortDropdownOpen && (
<div className="absolute top-full left-0 z-10 mt-1 w-full sm:w-48 rounded-lg border border-border bg-card shadow-lg"> <div className="border-border bg-card absolute top-full left-0 z-10 mt-1 w-full rounded-lg border shadow-lg sm:w-48">
<div className="p-2"> <div className="p-2">
<button <button
onClick={() => { onClick={() => {
updateFilters({ sortBy: "name" }); updateFilters({ sortBy: "name" });
setIsSortDropdownOpen(false); setIsSortDropdownOpen(false);
}} }}
className={`w-full flex items-center space-x-3 rounded-md px-3 py-2 text-left hover:bg-accent ${ className={`hover:bg-accent flex w-full items-center space-x-3 rounded-md px-3 py-2 text-left ${
filters.sortBy === "name" ? "bg-primary/10 text-primary" : "text-muted-foreground" filters.sortBy === "name"
? "bg-primary/10 text-primary"
: "text-muted-foreground"
}`} }`}
> >
<FileText className="h-4 w-4" /> <FileText className="h-4 w-4" />
@@ -388,8 +411,10 @@ export function FilterBar({
updateFilters({ sortBy: "created" }); updateFilters({ sortBy: "created" });
setIsSortDropdownOpen(false); setIsSortDropdownOpen(false);
}} }}
className={`w-full flex items-center space-x-3 rounded-md px-3 py-2 text-left hover:bg-accent ${ className={`hover:bg-accent flex w-full items-center space-x-3 rounded-md px-3 py-2 text-left ${
filters.sortBy === "created" ? "bg-primary/10 text-primary" : "text-muted-foreground" filters.sortBy === "created"
? "bg-primary/10 text-primary"
: "text-muted-foreground"
}`} }`}
> >
<Calendar className="h-4 w-4" /> <Calendar className="h-4 w-4" />
@@ -409,7 +434,7 @@ export function FilterBar({
} }
variant="outline" variant="outline"
size="default" size="default"
className="w-full sm:w-auto flex items-center justify-center space-x-1 bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground" className="bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground flex w-full items-center justify-center space-x-1 sm:w-auto"
> >
{filters.sortOrder === "asc" ? ( {filters.sortOrder === "asc" ? (
<> <>
@@ -454,18 +479,16 @@ export function FilterBar({
</div> </div>
{/* Filter Summary and Clear All */} {/* Filter Summary and Clear All */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2"> <div className="flex flex-col items-start justify-between gap-2 sm:flex-row sm:items-center">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="text-sm text-muted-foreground"> <div className="text-muted-foreground text-sm">
{filteredCount === totalScripts ? ( {filteredCount === totalScripts ? (
<span>Showing all {totalScripts} scripts</span> <span>Showing all {totalScripts} scripts</span>
) : ( ) : (
<span> <span>
{filteredCount} of {totalScripts} scripts{" "} {filteredCount} of {totalScripts} scripts{" "}
{hasActiveFilters && ( {hasActiveFilters && (
<span className="font-medium text-info"> <span className="text-info font-medium">(filtered)</span>
(filtered)
</span>
)} )}
</span> </span>
)} )}
@@ -473,9 +496,17 @@ export function FilterBar({
{/* Filter Persistence Status */} {/* Filter Persistence Status */}
{!isLoadingFilters && saveFiltersEnabled && ( {!isLoadingFilters && saveFiltersEnabled && (
<div className="flex items-center space-x-1 text-xs text-success"> <div className="text-success flex items-center space-x-1 text-xs">
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20"> <svg
<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" /> className="h-3 w-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> </svg>
<span>Filters are being saved automatically</span> <span>Filters are being saved automatically</span>
</div> </div>
@@ -487,7 +518,7 @@ export function FilterBar({
onClick={clearAllFilters} onClick={clearAllFilters}
variant="ghost" variant="ghost"
size="sm" size="sm"
className="flex items-center space-x-1 text-error hover:bg-error/10 hover:text-error-foreground w-full sm:w-auto justify-center sm:justify-start" className="text-error hover:bg-error/10 hover:text-error-foreground flex w-full items-center justify-center space-x-1 sm:w-auto sm:justify-start"
> >
<svg <svg
className="h-4 w-4" className="h-4 w-4"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,34 +1,45 @@
'use client'; "use client";
import { Loader2, CheckCircle, X } from 'lucide-react'; import { Loader2, CheckCircle, X } from "lucide-react";
import { useRegisterModal } from './modal/ModalStackProvider'; import { useRegisterModal } from "./modal/ModalStackProvider";
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from "react";
import { Button } from './ui/button'; import { Button } from "./ui/button";
interface LoadingModalProps { interface LoadingModalProps {
isOpen: boolean; isOpen: boolean;
action: string; action?: string;
logs?: string[]; logs?: string[];
isComplete?: boolean; isComplete?: boolean;
title?: string; title?: string;
onClose?: () => void; onClose?: () => void;
} }
export function LoadingModal({ isOpen, action, logs = [], isComplete = false, title, onClose }: LoadingModalProps) { export function LoadingModal({
isOpen,
action: _action,
logs = [],
isComplete = false,
title,
onClose,
}: LoadingModalProps) {
// Allow dismissing with ESC only when complete, prevent during running // Allow dismissing with ESC only when complete, prevent during running
useRegisterModal(isOpen, { id: 'loading-modal', allowEscape: isComplete, onClose: onClose || (() => null) }); useRegisterModal(isOpen, {
id: "loading-modal",
allowEscape: isComplete,
onClose: onClose ?? (() => null),
});
const logsEndRef = useRef<HTMLDivElement>(null); const logsEndRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom when new logs arrive // Auto-scroll to bottom when new logs arrive
useEffect(() => { useEffect(() => {
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' }); logsEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [logs]); }, [logs]);
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<div className="bg-card rounded-lg shadow-xl max-w-2xl w-full border border-border p-8 max-h-[80vh] flex flex-col relative"> <div className="bg-card border-border relative flex max-h-[80vh] w-full max-w-2xl flex-col rounded-lg border p-8 shadow-xl">
{/* Close button - only show when complete */} {/* Close button - only show when complete */}
{isComplete && onClose && ( {isComplete && onClose && (
<Button <Button
@@ -44,27 +55,26 @@ export function LoadingModal({ isOpen, action, logs = [], isComplete = false, ti
<div className="flex flex-col items-center space-y-4"> <div className="flex flex-col items-center space-y-4">
<div className="relative"> <div className="relative">
{isComplete ? ( {isComplete ? (
<CheckCircle className="h-12 w-12 text-success" /> <CheckCircle className="text-success h-12 w-12" />
) : ( ) : (
<> <>
<Loader2 className="h-12 w-12 animate-spin text-primary" /> <Loader2 className="text-primary h-12 w-12 animate-spin" />
<div className="absolute inset-0 rounded-full border-2 border-primary/20 animate-pulse"></div> <div className="border-primary/20 absolute inset-0 animate-pulse rounded-full border-2"></div>
</> </>
)} )}
</div> </div>
{/* Static title text */} {/* Static title text */}
{title && ( {title && <p className="text-muted-foreground text-sm">{title}</p>}
<p className="text-sm text-muted-foreground">
{title}
</p>
)}
{/* Log output */} {/* Log output */}
{logs.length > 0 && ( {logs.length > 0 && (
<div className="w-full bg-card border border-border rounded-lg p-4 font-mono text-xs text-chart-2 max-h-[60vh] overflow-y-auto terminal-output"> <div className="bg-card border-border text-chart-2 terminal-output max-h-[60vh] w-full overflow-y-auto rounded-lg border p-4 font-mono text-xs">
{logs.map((log, index) => ( {logs.map((log, index) => (
<div key={index} className="mb-1 whitespace-pre-wrap break-words"> <div
key={index}
className="mb-1 break-words whitespace-pre-wrap"
>
{log} {log}
</div> </div>
))} ))}
@@ -74,9 +84,15 @@ export function LoadingModal({ isOpen, action, logs = [], isComplete = false, ti
{!isComplete && ( {!isComplete && (
<div className="flex space-x-1"> <div className="flex space-x-1">
<div className="w-2 h-2 bg-primary rounded-full animate-bounce"></div> <div className="bg-primary h-2 w-2 animate-bounce rounded-full"></div>
<div className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div> <div
<div className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div> className="bg-primary h-2 w-2 animate-bounce rounded-full"
style={{ animationDelay: "0.1s" }}
></div>
<div
className="bg-primary h-2 w-2 animate-bounce rounded-full"
style={{ animationDelay: "0.2s" }}
></div>
</div> </div>
)} )}
</div> </div>
@@ -84,4 +100,3 @@ export function LoadingModal({ isOpen, action, logs = [], isComplete = false, ti
</div> </div>
); );
} }

View File

@@ -1,11 +1,11 @@
'use client'; "use client";
import { useState, useEffect } from 'react'; import { useState, useEffect } from "react";
import { Button } from './ui/button'; import { Button } from "./ui/button";
import { Lock, CheckCircle, AlertCircle } from 'lucide-react'; import { Lock, CheckCircle, AlertCircle } from "lucide-react";
import { useRegisterModal } from './modal/ModalStackProvider'; import { useRegisterModal } from "./modal/ModalStackProvider";
import { api } from '~/trpc/react'; import { api } from "~/trpc/react";
import type { Storage } from '~/server/services/storageService'; import type { Storage } from "~/server/services/storageService";
interface PBSCredentialsModalProps { interface PBSCredentialsModalProps {
isOpen: boolean; isOpen: boolean;
@@ -19,23 +19,25 @@ export function PBSCredentialsModal({
isOpen, isOpen,
onClose, onClose,
serverId, serverId,
serverName, serverName: _serverName,
storage storage,
}: PBSCredentialsModalProps) { }: PBSCredentialsModalProps) {
const [pbsIp, setPbsIp] = useState(''); const [pbsIp, setPbsIp] = useState("");
const [pbsDatastore, setPbsDatastore] = useState(''); const [pbsDatastore, setPbsDatastore] = useState("");
const [pbsPassword, setPbsPassword] = useState(''); const [pbsPassword, setPbsPassword] = useState("");
const [pbsFingerprint, setPbsFingerprint] = useState(''); const [pbsFingerprint, setPbsFingerprint] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
// Extract PBS info from storage object // Extract PBS info from storage object
const pbsIpFromStorage = (storage as any).server || null; const pbsIpFromStorage = (storage as { server?: string }).server ?? null;
const pbsDatastoreFromStorage = (storage as any).datastore || null; const pbsDatastoreFromStorage =
(storage as { datastore?: string }).datastore ?? null;
// Fetch existing credentials // Fetch existing credentials
const { data: credentialData, refetch } = api.pbsCredentials.getCredentialsForStorage.useQuery( const { data: credentialData, refetch } =
api.pbsCredentials.getCredentialsForStorage.useQuery(
{ serverId, storageName: storage.name }, { serverId, storageName: storage.name },
{ enabled: isOpen } { enabled: isOpen },
); );
// Initialize form with storage config values or existing credentials // Initialize form with storage config values or existing credentials
@@ -45,14 +47,14 @@ export function PBSCredentialsModal({
// Load existing credentials // Load existing credentials
setPbsIp(credentialData.credential.pbs_ip); setPbsIp(credentialData.credential.pbs_ip);
setPbsDatastore(credentialData.credential.pbs_datastore); setPbsDatastore(credentialData.credential.pbs_datastore);
setPbsPassword(''); // Don't show password setPbsPassword(""); // Don't show password
setPbsFingerprint(credentialData.credential.pbs_fingerprint || ''); setPbsFingerprint(credentialData.credential.pbs_fingerprint ?? "");
} else { } else {
// Initialize with storage config values // Initialize with storage config values
setPbsIp(pbsIpFromStorage || ''); setPbsIp(pbsIpFromStorage ?? "");
setPbsDatastore(pbsDatastoreFromStorage || ''); setPbsDatastore(pbsDatastoreFromStorage ?? "");
setPbsPassword(''); setPbsPassword("");
setPbsFingerprint(''); setPbsFingerprint("");
} }
} }
}, [isOpen, credentialData, pbsIpFromStorage, pbsDatastoreFromStorage]); }, [isOpen, credentialData, pbsIpFromStorage, pbsDatastoreFromStorage]);
@@ -63,7 +65,7 @@ export function PBSCredentialsModal({
onClose(); onClose();
}, },
onError: (error) => { onError: (error) => {
console.error('Failed to save PBS credentials:', error); console.error("Failed to save PBS credentials:", error);
alert(`Failed to save credentials: ${error.message}`); alert(`Failed to save credentials: ${error.message}`);
}, },
}); });
@@ -74,18 +76,22 @@ export function PBSCredentialsModal({
onClose(); onClose();
}, },
onError: (error) => { onError: (error) => {
console.error('Failed to delete PBS credentials:', error); console.error("Failed to delete PBS credentials:", error);
alert(`Failed to delete credentials: ${error.message}`); alert(`Failed to delete credentials: ${error.message}`);
}, },
}); });
useRegisterModal(isOpen, { id: 'pbs-credentials-modal', allowEscape: true, onClose }); useRegisterModal(isOpen, {
id: "pbs-credentials-modal",
allowEscape: true,
onClose,
});
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!pbsIp || !pbsDatastore || !pbsFingerprint) { if (!pbsIp || !pbsDatastore || !pbsFingerprint) {
alert('Please fill in all required fields (IP, Datastore, Fingerprint)'); alert("Please fill in all required fields (IP, Datastore, Fingerprint)");
return; return;
} }
@@ -106,7 +112,11 @@ export function PBSCredentialsModal({
}; };
const handleDelete = async () => { const handleDelete = async () => {
if (!confirm('Are you sure you want to delete the PBS credentials for this storage?')) { if (
!confirm(
"Are you sure you want to delete the PBS credentials for this storage?",
)
) {
return; return;
} }
@@ -126,13 +136,13 @@ export function PBSCredentialsModal({
const hasCredentials = credentialData?.success && credentialData.credential; const hasCredentials = credentialData?.success && credentialData.credential;
return ( return (
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<div className="bg-card rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] flex flex-col border border-border"> <div className="bg-card border-border flex max-h-[90vh] w-full max-w-2xl flex-col rounded-lg border shadow-xl">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border"> <div className="border-border flex items-center justify-between border-b p-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Lock className="h-6 w-6 text-primary" /> <Lock className="text-primary h-6 w-6" />
<h2 className="text-2xl font-bold text-card-foreground"> <h2 className="text-card-foreground text-2xl font-bold">
PBS Credentials - {storage.name} PBS Credentials - {storage.name}
</h2> </h2>
</div> </div>
@@ -142,8 +152,18 @@ export function PBSCredentialsModal({
size="icon" size="icon"
className="text-muted-foreground hover:text-foreground" className="text-muted-foreground hover:text-foreground"
> >
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg> </svg>
</Button> </Button>
</div> </div>
@@ -153,7 +173,10 @@ export function PBSCredentialsModal({
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
{/* Storage Name (read-only) */} {/* Storage Name (read-only) */}
<div> <div>
<label htmlFor="storage-name" className="block text-sm font-medium text-foreground mb-1"> <label
htmlFor="storage-name"
className="text-foreground mb-1 block text-sm font-medium"
>
Storage Name Storage Name
</label> </label>
<input <input
@@ -161,13 +184,16 @@ export function PBSCredentialsModal({
id="storage-name" id="storage-name"
value={storage.name} value={storage.name}
disabled disabled
className="w-full px-3 py-2 border rounded-md shadow-sm bg-muted text-muted-foreground border-border cursor-not-allowed" className="bg-muted text-muted-foreground border-border w-full cursor-not-allowed rounded-md border px-3 py-2 shadow-sm"
/> />
</div> </div>
{/* PBS IP */} {/* PBS IP */}
<div> <div>
<label htmlFor="pbs-ip" className="block text-sm font-medium text-foreground mb-1"> <label
htmlFor="pbs-ip"
className="text-foreground mb-1 block text-sm font-medium"
>
PBS Server IP <span className="text-error">*</span> PBS Server IP <span className="text-error">*</span>
</label> </label>
<input <input
@@ -177,17 +203,20 @@ export function PBSCredentialsModal({
onChange={(e) => setPbsIp(e.target.value)} onChange={(e) => setPbsIp(e.target.value)}
required required
disabled={isLoading} disabled={isLoading}
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring border-border" className="bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring border-border w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none"
placeholder="e.g., 10.10.10.226" placeholder="e.g., 10.10.10.226"
/> />
<p className="mt-1 text-xs text-muted-foreground"> <p className="text-muted-foreground mt-1 text-xs">
IP address of the Proxmox Backup Server IP address of the Proxmox Backup Server
</p> </p>
</div> </div>
{/* PBS Datastore */} {/* PBS Datastore */}
<div> <div>
<label htmlFor="pbs-datastore" className="block text-sm font-medium text-foreground mb-1"> <label
htmlFor="pbs-datastore"
className="text-foreground mb-1 block text-sm font-medium"
>
PBS Datastore <span className="text-error">*</span> PBS Datastore <span className="text-error">*</span>
</label> </label>
<input <input
@@ -197,18 +226,22 @@ export function PBSCredentialsModal({
onChange={(e) => setPbsDatastore(e.target.value)} onChange={(e) => setPbsDatastore(e.target.value)}
required required
disabled={isLoading} disabled={isLoading}
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring border-border" className="bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring border-border w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none"
placeholder="e.g., NAS03-ISCSI-BACKUP" placeholder="e.g., NAS03-ISCSI-BACKUP"
/> />
<p className="mt-1 text-xs text-muted-foreground"> <p className="text-muted-foreground mt-1 text-xs">
Name of the datastore on the PBS server Name of the datastore on the PBS server
</p> </p>
</div> </div>
{/* PBS Password */} {/* PBS Password */}
<div> <div>
<label htmlFor="pbs-password" className="block text-sm font-medium text-foreground mb-1"> <label
Password {!hasCredentials && <span className="text-error">*</span>} htmlFor="pbs-password"
className="text-foreground mb-1 block text-sm font-medium"
>
Password{" "}
{!hasCredentials && <span className="text-error">*</span>}
</label> </label>
<input <input
type="password" type="password"
@@ -217,17 +250,24 @@ export function PBSCredentialsModal({
onChange={(e) => setPbsPassword(e.target.value)} onChange={(e) => setPbsPassword(e.target.value)}
required={!hasCredentials} required={!hasCredentials}
disabled={isLoading} disabled={isLoading}
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring border-border" className="bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring border-border w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none"
placeholder={hasCredentials ? "Enter new password (leave empty to keep existing)" : "Enter PBS password"} placeholder={
hasCredentials
? "Enter new password (leave empty to keep existing)"
: "Enter PBS password"
}
/> />
<p className="mt-1 text-xs text-muted-foreground"> <p className="text-muted-foreground mt-1 text-xs">
Password for root@pam user on PBS server Password for root@pam user on PBS server
</p> </p>
</div> </div>
{/* PBS Fingerprint */} {/* PBS Fingerprint */}
<div> <div>
<label htmlFor="pbs-fingerprint" className="block text-sm font-medium text-foreground mb-1"> <label
htmlFor="pbs-fingerprint"
className="text-foreground mb-1 block text-sm font-medium"
>
Fingerprint <span className="text-error">*</span> Fingerprint <span className="text-error">*</span>
</label> </label>
<input <input
@@ -237,35 +277,37 @@ export function PBSCredentialsModal({
onChange={(e) => setPbsFingerprint(e.target.value)} onChange={(e) => setPbsFingerprint(e.target.value)}
required required
disabled={isLoading} disabled={isLoading}
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring border-border" className="bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring border-border w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none"
placeholder="e.g., 7b:e5:87:38:5e:16:05:d1:12:22:7f:73:d2:e2:d0:cf:8c:cb:28:e2:74:0c:78:91:1a:71:74:2e:79:20:5a:02" placeholder="e.g., 7b:e5:87:38:5e:16:05:d1:12:22:7f:73:d2:e2:d0:cf:8c:cb:28:e2:74:0c:78:91:1a:71:74:2e:79:20:5a:02"
/> />
<p className="mt-1 text-xs text-muted-foreground"> <p className="text-muted-foreground mt-1 text-xs">
Server fingerprint for auto-acceptance. You can find this on your PBS dashboard by clicking the "Show Fingerprint" button. Server fingerprint for auto-acceptance. You can find this on
your PBS dashboard by clicking the &quot;Show Fingerprint&quot;
button.
</p> </p>
</div> </div>
{/* Status indicator */} {/* Status indicator */}
{hasCredentials && ( {hasCredentials && (
<div className="p-3 bg-success/10 border border-success/20 rounded-lg flex items-center gap-2"> <div className="bg-success/10 border-success/20 flex items-center gap-2 rounded-lg border p-3">
<CheckCircle className="h-4 w-4 text-success" /> <CheckCircle className="text-success h-4 w-4" />
<span className="text-sm text-success font-medium"> <span className="text-success text-sm font-medium">
Credentials are configured for this storage Credentials are configured for this storage
</span> </span>
</div> </div>
)} )}
{/* Action Buttons */} {/* Action Buttons */}
<div className="flex flex-col sm:flex-row justify-end gap-3 pt-4"> <div className="flex flex-col justify-end gap-3 pt-4 sm:flex-row">
{hasCredentials && ( {hasCredentials && (
<Button <Button
type="button" type="button"
onClick={handleDelete} onClick={handleDelete}
variant="outline" variant="outline"
disabled={isLoading} disabled={isLoading}
className="w-full sm:w-auto order-3" className="order-3 w-full sm:w-auto"
> >
<AlertCircle className="h-4 w-4 mr-2" /> <AlertCircle className="mr-2 h-4 w-4" />
Delete Credentials Delete Credentials
</Button> </Button>
)} )}
@@ -274,7 +316,7 @@ export function PBSCredentialsModal({
onClick={onClose} onClick={onClose}
variant="outline" variant="outline"
disabled={isLoading} disabled={isLoading}
className="w-full sm:w-auto order-2" className="order-2 w-full sm:w-auto"
> >
Cancel Cancel
</Button> </Button>
@@ -282,9 +324,13 @@ export function PBSCredentialsModal({
type="submit" type="submit"
variant="default" variant="default"
disabled={isLoading} disabled={isLoading}
className="w-full sm:w-auto order-1" className="order-1 w-full sm:w-auto"
> >
{isLoading ? 'Saving...' : hasCredentials ? 'Update Credentials' : 'Save Credentials'} {isLoading
? "Saving..."
: hasCredentials
? "Update Credentials"
: "Save Credentials"}
</Button> </Button>
</div> </div>
</form> </form>
@@ -293,4 +339,3 @@ export function PBSCredentialsModal({
</div> </div>
); );
} }

View File

@@ -1,9 +1,9 @@
'use client'; "use client";
import { useState } from 'react'; import { useState } from "react";
import Image from 'next/image'; import Image from "next/image";
import type { ScriptCard } from '~/types/script'; import type { ScriptCard } from "~/types/script";
import { TypeBadge, UpdateableBadge } from './Badge'; import { TypeBadge, UpdateableBadge } from "./Badge";
interface ScriptCardProps { interface ScriptCardProps {
script: ScriptCard; script: ScriptCard;
@@ -12,7 +12,12 @@ interface ScriptCardProps {
onToggleSelect?: (slug: string) => void; onToggleSelect?: (slug: string) => void;
} }
export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect }: ScriptCardProps) { export function ScriptCard({
script,
onClick,
isSelected = false,
onToggleSelect,
}: ScriptCardProps) {
const [imageError, setImageError] = useState(false); const [imageError, setImageError] = useState(false);
const handleImageError = () => { const handleImageError = () => {
@@ -27,8 +32,8 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
}; };
const getRepoName = (url?: string): string => { const getRepoName = (url?: string): string => {
if (!url) return ''; if (!url) return "";
const match = url.match(/github\.com\/([^\/]+)\/([^\/]+)/); const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(url);
if (match) { if (match) {
return `${match[1]}/${match[2]}`; return `${match[1]}/${match[2]}`;
} }
@@ -37,32 +42,36 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
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 relative" className="bg-card border-border hover:border-primary relative flex h-full cursor-pointer flex-col rounded-lg border shadow-md transition-shadow duration-200 hover:shadow-lg"
onClick={() => onClick(script)} onClick={() => onClick(script)}
> >
{/* Checkbox in top-left corner */} {/* Checkbox in top-left corner */}
{onToggleSelect && ( {onToggleSelect && (
<div className="absolute top-2 left-2 z-10"> <div className="absolute top-2 left-2 z-10">
<div <div
className={`w-4 h-4 border-2 rounded cursor-pointer transition-all duration-200 flex items-center justify-center ${ className={`flex h-4 w-4 cursor-pointer items-center justify-center rounded border-2 transition-all duration-200 ${
isSelected isSelected
? 'bg-primary border-primary text-primary-foreground' ? "bg-primary border-primary text-primary-foreground"
: 'bg-card border-border hover:border-primary/60 hover:bg-accent' : "bg-card border-border hover:border-primary/60 hover:bg-accent"
}`} }`}
onClick={handleCheckboxClick} onClick={handleCheckboxClick}
> >
{isSelected && ( {isSelected && (
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"> <svg className="h-3 w-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" /> <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> </svg>
)} )}
</div> </div>
</div> </div>
)} )}
<div className="p-6 flex-1 flex flex-col"> <div className="flex flex-1 flex-col p-6">
{/* Header with logo and name */} {/* Header with logo and name */}
<div className="flex items-start space-x-4 mb-4"> <div className="mb-4 flex items-start space-x-4">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
{script.logo && !imageError ? ( {script.logo && !imageError ? (
<Image <Image
@@ -70,28 +79,31 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
alt={`${script.name} logo`} alt={`${script.name} logo`}
width={48} width={48}
height={48} height={48}
className="w-12 h-12 rounded-lg object-contain" className="h-12 w-12 rounded-lg object-contain"
onError={handleImageError} onError={handleImageError}
/> />
) : ( ) : (
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center"> <div className="bg-muted flex h-12 w-12 items-center justify-center rounded-lg">
<span className="text-muted-foreground text-lg font-semibold"> <span className="text-muted-foreground text-lg font-semibold">
{script.name?.charAt(0)?.toUpperCase() || '?'} {script.name?.charAt(0)?.toUpperCase() || "?"}
</span> </span>
</div> </div>
)} )}
</div> </div>
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<h3 className="text-lg font-semibold text-foreground truncate"> <h3 className="text-foreground truncate text-lg font-semibold">
{script.name || 'Unnamed Script'} {script.name || "Unnamed Script"}
</h3> </h3>
<div className="mt-2 space-y-2"> <div className="mt-2 space-y-2">
{/* Type and Updateable status on first row */} {/* Type and Updateable status on first row */}
<div className="flex items-center space-x-2 flex-wrap gap-1"> <div className="flex flex-wrap items-center gap-1 space-x-2">
<TypeBadge type={script.type ?? 'unknown'} /> <TypeBadge type={script.type ?? "unknown"} />
{script.updateable && <UpdateableBadge />} {script.updateable && <UpdateableBadge />}
{script.repository_url && ( {script.repository_url && (
<span className="text-xs px-2 py-0.5 bg-muted text-muted-foreground rounded border border-border" title={script.repository_url}> <span
className="bg-muted text-muted-foreground border-border rounded border px-2 py-0.5 text-xs"
title={script.repository_url}
>
{getRepoName(script.repository_url)} {getRepoName(script.repository_url)}
</span> </span>
)} )}
@@ -99,13 +111,17 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
{/* Download Status */} {/* Download Status */}
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<div className={`w-2 h-2 rounded-full ${ <div
script.isDownloaded ? 'bg-success' : 'bg-error' className={`h-2 w-2 rounded-full ${
}`}></div> script.isDownloaded ? "bg-success" : "bg-error"
<span className={`text-xs font-medium ${ }`}
script.isDownloaded ? 'text-success' : 'text-error' ></div>
}`}> <span
{script.isDownloaded ? 'Downloaded' : 'Not Downloaded'} className={`text-xs font-medium ${
script.isDownloaded ? "text-success" : "text-error"
}`}
>
{script.isDownloaded ? "Downloaded" : "Not Downloaded"}
</span> </span>
</div> </div>
</div> </div>
@@ -113,8 +129,8 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
</div> </div>
{/* Description */} {/* Description */}
<p className="text-muted-foreground text-sm line-clamp-3 mb-4 flex-1"> <p className="text-muted-foreground mb-4 line-clamp-3 flex-1 text-sm">
{script.description || 'No description available'} {script.description || "No description available"}
</p> </p>
{/* Footer with website link */} {/* Footer with website link */}
@@ -124,12 +140,22 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
href={script.website} href={script.website}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-info hover:text-info/80 text-sm font-medium flex items-center space-x-1" className="text-info hover:text-info/80 flex items-center space-x-1 text-sm font-medium"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<span>Website</span> <span>Website</span>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /> className="h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg> </svg>
</a> </a>
</div> </div>

View File

@@ -1,9 +1,9 @@
'use client'; "use client";
import { useState } from 'react'; import { useState } from "react";
import Image from 'next/image'; import Image from "next/image";
import type { ScriptCard } from '~/types/script'; import type { ScriptCard } from "~/types/script";
import { TypeBadge, UpdateableBadge } from './Badge'; import { TypeBadge, UpdateableBadge } from "./Badge";
interface ScriptCardListProps { interface ScriptCardListProps {
script: ScriptCard; script: ScriptCard;
@@ -12,7 +12,12 @@ interface ScriptCardListProps {
onToggleSelect?: (slug: string) => void; onToggleSelect?: (slug: string) => void;
} }
export function ScriptCardList({ script, onClick, isSelected = false, onToggleSelect }: ScriptCardListProps) { export function ScriptCardList({
script,
onClick,
isSelected = false,
onToggleSelect,
}: ScriptCardListProps) {
const [imageError, setImageError] = useState(false); const [imageError, setImageError] = useState(false);
const handleImageError = () => { const handleImageError = () => {
@@ -27,26 +32,27 @@ export function ScriptCardList({ script, onClick, isSelected = false, onToggleSe
}; };
const formatDate = (dateString?: string) => { const formatDate = (dateString?: string) => {
if (!dateString) return 'Unknown'; if (!dateString) return "Unknown";
try { try {
return new Date(dateString).toLocaleDateString('en-US', { return new Date(dateString).toLocaleDateString("en-US", {
year: 'numeric', year: "numeric",
month: 'short', month: "short",
day: 'numeric' day: "numeric",
}); });
} catch { } catch {
return 'Unknown'; return "Unknown";
} }
}; };
const getCategoryNames = () => { const getCategoryNames = () => {
if (!script.categoryNames || script.categoryNames.length === 0) return 'Uncategorized'; if (!script.categoryNames || script.categoryNames.length === 0)
return script.categoryNames.join(', '); return "Uncategorized";
return script.categoryNames.join(", ");
}; };
const getRepoName = (url?: string): string => { const getRepoName = (url?: string): string => {
if (!url) return ''; if (!url) return "";
const match = url.match(/github\.com\/([^\/]+)\/([^\/]+)/); const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(url);
if (match) { if (match) {
return `${match[1]}/${match[2]}`; return `${match[1]}/${match[2]}`;
} }
@@ -55,30 +61,34 @@ export function ScriptCardList({ script, onClick, isSelected = false, onToggleSe
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 relative" className="bg-card border-border hover:border-primary relative cursor-pointer rounded-lg border shadow-sm transition-shadow duration-200 hover:shadow-md"
onClick={() => onClick(script)} onClick={() => onClick(script)}
> >
{/* Checkbox */} {/* Checkbox */}
{onToggleSelect && ( {onToggleSelect && (
<div className="absolute top-4 left-4 z-10"> <div className="absolute top-4 left-4 z-10">
<div <div
className={`w-4 h-4 border-2 rounded cursor-pointer transition-all duration-200 flex items-center justify-center ${ className={`flex h-4 w-4 cursor-pointer items-center justify-center rounded border-2 transition-all duration-200 ${
isSelected isSelected
? 'bg-primary border-primary text-primary-foreground' ? "bg-primary border-primary text-primary-foreground"
: 'bg-card border-border hover:border-primary/60 hover:bg-accent' : "bg-card border-border hover:border-primary/60 hover:bg-accent"
}`} }`}
onClick={handleCheckboxClick} onClick={handleCheckboxClick}
> >
{isSelected && ( {isSelected && (
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"> <svg className="h-3 w-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" /> <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> </svg>
)} )}
</div> </div>
</div> </div>
)} )}
<div className={`p-6 ${onToggleSelect ? 'pl-12' : ''}`}> <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">
@@ -88,42 +98,49 @@ export function ScriptCardList({ script, onClick, isSelected = false, onToggleSe
alt={`${script.name} logo`} alt={`${script.name} logo`}
width={56} width={56}
height={56} height={56}
className="w-14 h-14 rounded-lg object-contain" className="h-14 w-14 rounded-lg object-contain"
onError={handleImageError} onError={handleImageError}
/> />
) : ( ) : (
<div className="w-14 h-14 bg-muted rounded-lg flex items-center justify-center"> <div className="bg-muted flex h-14 w-14 items-center justify-center rounded-lg">
<span className="text-muted-foreground text-lg font-semibold"> <span className="text-muted-foreground text-lg font-semibold">
{script.name?.charAt(0)?.toUpperCase() || '?'} {script.name?.charAt(0)?.toUpperCase() || "?"}
</span> </span>
</div> </div>
)} )}
</div> </div>
{/* Main Content */} {/* Main Content */}
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
{/* Header Row */} {/* Header Row */}
<div className="flex items-start justify-between mb-3"> <div className="mb-3 flex items-start justify-between">
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<h3 className="text-xl font-semibold text-foreground truncate mb-2"> <h3 className="text-foreground mb-2 truncate text-xl font-semibold">
{script.name || 'Unnamed Script'} {script.name || "Unnamed Script"}
</h3> </h3>
<div className="flex items-center space-x-3 flex-wrap gap-2"> <div className="flex flex-wrap items-center gap-2 space-x-3">
<TypeBadge type={script.type ?? 'unknown'} /> <TypeBadge type={script.type ?? "unknown"} />
{script.updateable && <UpdateableBadge />} {script.updateable && <UpdateableBadge />}
{script.repository_url && ( {script.repository_url && (
<span className="text-xs px-2 py-0.5 bg-muted text-muted-foreground rounded border border-border" title={script.repository_url}> <span
className="bg-muted text-muted-foreground border-border rounded border px-2 py-0.5 text-xs"
title={script.repository_url}
>
{getRepoName(script.repository_url)} {getRepoName(script.repository_url)}
</span> </span>
)} )}
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<div className={`w-2 h-2 rounded-full ${ <div
script.isDownloaded ? 'bg-success' : 'bg-error' className={`h-2 w-2 rounded-full ${
}`}></div> script.isDownloaded ? "bg-success" : "bg-error"
<span className={`text-sm font-medium ${ }`}
script.isDownloaded ? 'text-success' : 'text-error' ></div>
}`}> <span
{script.isDownloaded ? 'Downloaded' : 'Not Downloaded'} className={`text-sm font-medium ${
script.isDownloaded ? "text-success" : "text-error"
}`}
>
{script.isDownloaded ? "Downloaded" : "Not Downloaded"}
</span> </span>
</div> </div>
</div> </div>
@@ -135,68 +152,128 @@ export function ScriptCardList({ script, onClick, isSelected = false, onToggleSe
href={script.website} href={script.website}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-info hover:text-info/80 text-sm font-medium flex items-center space-x-1 ml-4" className="text-info hover:text-info/80 ml-4 flex items-center space-x-1 text-sm font-medium"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<span>Website</span> <span>Website</span>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /> className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg> </svg>
</a> </a>
)} )}
</div> </div>
{/* Description */} {/* Description */}
<p className="text-muted-foreground text-sm mb-4 line-clamp-2"> <p className="text-muted-foreground mb-4 line-clamp-2 text-sm">
{script.description || 'No description available'} {script.description || "No description available"}
</p> </p>
{/* Metadata Row */} {/* Metadata Row */}
<div className="flex items-center justify-between text-xs text-muted-foreground"> <div className="text-muted-foreground flex items-center justify-between text-xs">
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" /> className="h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
/>
</svg> </svg>
<span>Categories: {getCategoryNames()}</span> <span>Categories: {getCategoryNames()}</span>
</div> </div>
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /> className="h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg> </svg>
<span>Created: {formatDate(script.date_created)}</span> <span>Created: {formatDate(script.date_created)}</span>
</div> </div>
{(script.os ?? script.version) && ( {(script.os ?? script.version) && (
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" /> className="h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
/>
</svg> </svg>
<span> <span>
{script.os && script.version {script.os && script.version
? `${script.os.charAt(0).toUpperCase() + script.os.slice(1)} ${script.version}` ? `${script.os.charAt(0).toUpperCase() + script.os.slice(1)} ${script.version}`
: script.os : script.os
? script.os.charAt(0).toUpperCase() + script.os.slice(1) ? script.os.charAt(0).toUpperCase() +
script.os.slice(1)
: script.version : script.version
? `Version ${script.version}` ? `Version ${script.version}`
: '' : ""}
}
</span> </span>
</div> </div>
)} )}
{script.interface_port && ( {script.interface_port && (
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /> className="h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg> </svg>
<span>Port: {script.interface_port}</span> <span>Port: {script.interface_port}</span>
</div> </div>
)} )}
</div> </div>
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> className="h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg> </svg>
<span>ID: {script.slug || 'unknown'}</span> <span>ID: {script.slug || "unknown"}</span>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -4,14 +4,20 @@ import { useState } from "react";
import Image from "next/image"; import Image from "next/image";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import type { Script } from "~/types/script"; import type { Script } from "~/types/script";
import type { Server } from "~/types/server";
import { DiffViewer } from "./DiffViewer"; import { DiffViewer } from "./DiffViewer";
import { TextViewer } from "./TextViewer"; import { TextViewer } from "./TextViewer";
import { ExecutionModeModal } from "./ExecutionModeModal"; import { ExecutionModeModal } from "./ExecutionModeModal";
import { ConfirmationModal } from "./ConfirmationModal"; import { ConfirmationModal } from "./ConfirmationModal";
import { ScriptVersionModal } from "./ScriptVersionModal"; import { ScriptVersionModal } from "./ScriptVersionModal";
import { TypeBadge, UpdateableBadge, PrivilegedBadge, NoteBadge } from "./Badge"; import {
TypeBadge,
UpdateableBadge,
PrivilegedBadge,
NoteBadge,
} from "./Badge";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { useRegisterModal } from './modal/ModalStackProvider'; import { useRegisterModal } from "./modal/ModalStackProvider";
interface ScriptDetailModalProps { interface ScriptDetailModalProps {
script: Script | null; script: Script | null;
@@ -21,7 +27,7 @@ interface ScriptDetailModalProps {
scriptPath: string, scriptPath: string,
scriptName: string, scriptName: string,
mode?: "local" | "ssh", mode?: "local" | "ssh",
server?: any, server?: Server,
) => void; ) => void;
} }
@@ -31,7 +37,11 @@ export function ScriptDetailModal({
onClose, onClose,
onInstallScript, onInstallScript,
}: ScriptDetailModalProps) { }: ScriptDetailModalProps) {
useRegisterModal(isOpen, { id: 'script-detail-modal', allowEscape: true, onClose }); useRegisterModal(isOpen, {
id: "script-detail-modal",
allowEscape: true,
onClose,
});
const [imageError, setImageError] = useState(false); const [imageError, setImageError] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [loadMessage, setLoadMessage] = useState<string | null>(null); const [loadMessage, setLoadMessage] = useState<string | null>(null);
@@ -40,7 +50,9 @@ export function ScriptDetailModal({
const [textViewerOpen, setTextViewerOpen] = useState(false); const [textViewerOpen, setTextViewerOpen] = useState(false);
const [executionModeOpen, setExecutionModeOpen] = useState(false); const [executionModeOpen, setExecutionModeOpen] = useState(false);
const [versionModalOpen, setVersionModalOpen] = useState(false); const [versionModalOpen, setVersionModalOpen] = useState(false);
const [selectedVersionType, setSelectedVersionType] = useState<string | null>(null); const [selectedVersionType, setSelectedVersionType] = useState<string | null>(
null,
);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
@@ -143,8 +155,9 @@ export function ScriptDetailModal({
// Check if script has multiple variants (default and alpine) // Check if script has multiple variants (default and alpine)
const installMethods = script.install_methods || []; const installMethods = script.install_methods || [];
const hasMultipleVariants = installMethods.filter(method => const hasMultipleVariants =
method.type === 'default' || method.type === 'alpine' installMethods.filter(
(method) => method.type === "default" || method.type === "alpine",
).length > 1; ).length > 1;
if (hasMultipleVariants) { if (hasMultipleVariants) {
@@ -153,9 +166,13 @@ export function ScriptDetailModal({
} else { } else {
// Only one variant, proceed directly to execution mode // Only one variant, proceed directly to execution mode
// Use the first available method or default to 'default' type // Use the first available method or default to 'default' type
const defaultMethod = installMethods.find(method => method.type === 'default'); const defaultMethod = installMethods.find(
(method) => method.type === "default",
);
const firstMethod = installMethods[0]; const firstMethod = installMethods[0];
setSelectedVersionType(defaultMethod?.type || firstMethod?.type || 'default'); setSelectedVersionType(
defaultMethod?.type ?? firstMethod?.type ?? "default",
);
setExecutionModeOpen(true); setExecutionModeOpen(true);
} }
}; };
@@ -166,16 +183,15 @@ export function ScriptDetailModal({
setExecutionModeOpen(true); setExecutionModeOpen(true);
}; };
const handleExecuteScript = (mode: "local" | "ssh", server?: any) => { const handleExecuteScript = (mode: "local" | "ssh", server?: Server) => {
if (!script || !onInstallScript) return; if (!script || !onInstallScript) return;
// Find the script path based on selected version type // Find the script path based on selected version type
const versionType = selectedVersionType || 'default'; const versionType = selectedVersionType ?? "default";
const scriptMethod = script.install_methods?.find( const scriptMethod =
script.install_methods?.find(
(method) => method.type === versionType && method.script, (method) => method.type === versionType && method.script,
) || script.install_methods?.find( ) ?? script.install_methods?.find((method) => method.script);
(method) => method.script,
);
if (scriptMethod?.script) { if (scriptMethod?.script) {
const scriptPath = `scripts/${scriptMethod.script}`; const scriptPath = `scripts/${scriptMethod.script}`;
@@ -207,31 +223,31 @@ export function ScriptDetailModal({
return ( return (
<div <div
className="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm bg-black/50" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
onClick={handleBackdropClick} onClick={handleBackdropClick}
> >
<div className="bg-card rounded-lg shadow-xl max-w-6xl w-full max-h-[95vh] min-h-[80vh] overflow-y-auto border border-border mx-2 sm:mx-4 lg:mx-0"> <div className="bg-card border-border mx-2 max-h-[95vh] min-h-[80vh] w-full max-w-6xl overflow-y-auto rounded-lg border shadow-xl sm:mx-4 lg:mx-0">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between border-b border-border p-4 sm:p-6"> <div className="border-border flex items-center justify-between border-b p-4 sm:p-6">
<div className="flex items-center space-x-3 sm:space-x-4 min-w-0 flex-1"> <div className="flex min-w-0 flex-1 items-center space-x-3 sm:space-x-4">
{script.logo && !imageError ? ( {script.logo && !imageError ? (
<Image <Image
src={script.logo} src={script.logo}
alt={`${script.name} logo`} alt={`${script.name} logo`}
width={64} width={64}
height={64} height={64}
className="h-12 w-12 sm:h-16 sm:w-16 rounded-lg object-contain flex-shrink-0" className="h-12 w-12 flex-shrink-0 rounded-lg object-contain sm:h-16 sm:w-16"
onError={handleImageError} onError={handleImageError}
/> />
) : ( ) : (
<div className="flex h-12 w-12 sm:h-16 sm:w-16 items-center justify-center rounded-lg bg-muted flex-shrink-0"> <div className="bg-muted flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-lg sm:h-16 sm:w-16">
<span className="text-lg sm:text-2xl font-semibold text-muted-foreground"> <span className="text-muted-foreground text-lg font-semibold sm:text-2xl">
{script.name.charAt(0).toUpperCase()} {script.name.charAt(0).toUpperCase()}
</span> </span>
</div> </div>
)} )}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h2 className="text-xl sm:text-2xl font-bold text-foreground truncate"> <h2 className="text-foreground truncate text-xl font-bold sm:text-2xl">
{script.name} {script.name}
</h2> </h2>
<div className="mt-1 flex flex-wrap items-center gap-1 sm:gap-2"> <div className="mt-1 flex flex-wrap items-center gap-1 sm:gap-2">
@@ -243,11 +259,13 @@ export function ScriptDetailModal({
href={script.repository_url} href={script.repository_url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-xs px-2 py-0.5 bg-muted text-muted-foreground rounded border border-border hover:bg-accent hover:text-foreground transition-colors" className="bg-muted text-muted-foreground border-border hover:bg-accent hover:text-foreground rounded border px-2 py-0.5 text-xs transition-colors"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
title={`Source: ${script.repository_url}`} title={`Source: ${script.repository_url}`}
> >
{script.repository_url.match(/github\.com\/([^\/]+)\/([^\/]+)/)?.[0]?.replace('https://', '') ?? script.repository_url} {/github\.com\/([^\/]+)\/([^\/]+)/
.exec(script.repository_url)?.[0]
?.replace("https://", "") ?? script.repository_url}
</a> </a>
)} )}
</div> </div>
@@ -255,12 +273,12 @@ export function ScriptDetailModal({
{/* Interface Port*/} {/* Interface Port*/}
{script.interface_port && ( {script.interface_port && (
<div className="ml-3 sm:ml-4 flex-shrink-0"> <div className="ml-3 flex-shrink-0 sm:ml-4">
<div className="bg-primary/10 border border-primary/30 rounded-lg px-3 py-1.5 sm:px-4 sm:py-2"> <div className="bg-primary/10 border-primary/30 rounded-lg border px-3 py-1.5 sm:px-4 sm:py-2">
<span className="text-xs sm:text-sm font-medium text-muted-foreground mr-2"> <span className="text-muted-foreground mr-2 text-xs font-medium sm:text-sm">
Port: Port:
</span> </span>
<span className="text-sm sm:text-base font-semibold text-foreground font-mono"> <span className="text-foreground font-mono text-sm font-semibold sm:text-base">
{script.interface_port} {script.interface_port}
</span> </span>
</div> </div>
@@ -273,7 +291,7 @@ export function ScriptDetailModal({
onClick={onClose} onClick={onClose}
variant="ghost" variant="ghost"
size="icon" size="icon"
className="text-muted-foreground hover:text-foreground flex-shrink-0 ml-4" className="text-muted-foreground hover:text-foreground ml-4 flex-shrink-0"
> >
<svg <svg
className="h-5 w-5 sm:h-6 sm:w-6" className="h-5 w-5 sm:h-6 sm:w-6"
@@ -292,7 +310,7 @@ export function ScriptDetailModal({
</div> </div>
{/* Action Buttons */} {/* Action Buttons */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center space-y-2 sm:space-y-0 sm:space-x-2 p-4 sm:p-6 border-b border-border"> <div className="border-border flex flex-col items-stretch space-y-2 border-b p-4 sm:flex-row sm:items-center sm:space-y-0 sm:space-x-2 sm:p-6">
{/* Install Button - only show if script files exist */} {/* Install Button - only show if script files exist */}
{scriptFilesData?.success && {scriptFilesData?.success &&
scriptFilesData.ctExists && scriptFilesData.ctExists &&
@@ -301,7 +319,7 @@ export function ScriptDetailModal({
onClick={handleInstallScript} onClick={handleInstallScript}
variant="outline" variant="outline"
size="default" size="default"
className="w-full sm:w-auto flex items-center justify-center space-x-2" className="flex w-full items-center justify-center space-x-2 sm:w-auto"
> >
<svg <svg
className="h-4 w-4" className="h-4 w-4"
@@ -327,7 +345,7 @@ export function ScriptDetailModal({
onClick={handleViewScript} onClick={handleViewScript}
variant="outline" variant="outline"
size="default" size="default"
className="w-full sm:w-auto flex items-center justify-center space-x-2" className="flex w-full items-center justify-center space-x-2 sm:w-auto"
> >
<svg <svg
className="h-4 w-4" className="h-4 w-4"
@@ -369,7 +387,7 @@ export function ScriptDetailModal({
disabled={isLoading} disabled={isLoading}
className={`flex items-center space-x-2 rounded-lg px-4 py-2 font-medium transition-colors ${ className={`flex items-center space-x-2 rounded-lg px-4 py-2 font-medium transition-colors ${
isLoading isLoading
? "cursor-not-allowed bg-muted text-muted-foreground" ? "bg-muted text-muted-foreground cursor-not-allowed"
: "bg-success text-success-foreground hover:bg-success/90" : "bg-success text-success-foreground hover:bg-success/90"
}`} }`}
> >
@@ -403,7 +421,7 @@ export function ScriptDetailModal({
return ( return (
<button <button
disabled disabled
className="flex cursor-not-allowed items-center space-x-2 rounded-lg bg-muted px-4 py-2 font-medium text-muted-foreground transition-colors" className="bg-muted text-muted-foreground flex cursor-not-allowed items-center space-x-2 rounded-lg px-4 py-2 font-medium transition-colors"
> >
<svg <svg
className="h-4 w-4" className="h-4 w-4"
@@ -429,7 +447,7 @@ export function ScriptDetailModal({
disabled={isLoading} disabled={isLoading}
className={`flex items-center space-x-2 rounded-lg px-4 py-2 font-medium transition-colors ${ className={`flex items-center space-x-2 rounded-lg px-4 py-2 font-medium transition-colors ${
isLoading isLoading
? "cursor-not-allowed bg-muted text-muted-foreground" ? "bg-muted text-muted-foreground cursor-not-allowed"
: "bg-warning text-warning-foreground hover:bg-warning/90" : "bg-warning text-warning-foreground hover:bg-warning/90"
}`} }`}
> >
@@ -469,7 +487,7 @@ export function ScriptDetailModal({
disabled={isDeleting} disabled={isDeleting}
variant="destructive" variant="destructive"
size="default" size="default"
className="w-full sm:w-auto flex items-center justify-center space-x-2" className="flex w-full items-center justify-center space-x-2 sm:w-auto"
> >
{isDeleting ? ( {isDeleting ? (
<> <>
@@ -499,12 +517,12 @@ export function ScriptDetailModal({
</div> </div>
{/* Content */} {/* Content */}
<div className="space-y-4 sm:space-y-6 p-4 sm:p-6"> <div className="space-y-4 p-4 sm:space-y-6 sm:p-6">
{/* Script Files Status */} {/* Script Files Status */}
{(scriptFilesLoading || comparisonLoading) && ( {(scriptFilesLoading || comparisonLoading) && (
<div className="mb-4 rounded-lg bg-primary/10 p-3 text-sm text-primary"> <div className="bg-primary/10 text-primary mb-4 rounded-lg p-3 text-sm">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-primary"></div> <div className="border-primary h-4 w-4 animate-spin rounded-full border-b-2"></div>
<span>Loading script status...</span> <span>Loading script status...</span>
</div> </div>
</div> </div>
@@ -527,8 +545,8 @@ export function ScriptDetailModal({
} }
return ( return (
<div className="mb-4 rounded-lg bg-muted p-3 text-sm text-muted-foreground"> <div className="bg-muted text-muted-foreground mb-4 rounded-lg p-3 text-sm">
<div className="flex flex-col sm:flex-row sm:items-center space-y-2 sm:space-y-0 sm:space-x-4"> <div className="flex flex-col space-y-2 sm:flex-row sm:items-center sm:space-y-0 sm:space-x-4">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div <div
className={`h-2 w-2 rounded-full ${scriptFilesData.ctExists ? "bg-success" : "bg-muted"}`} className={`h-2 w-2 rounded-full ${scriptFilesData.ctExists ? "bg-success" : "bg-muted"}`}
@@ -567,31 +585,33 @@ export function ScriptDetailModal({
</> </>
) : comparisonLoading ? ( ) : comparisonLoading ? (
<> <>
<div className="h-2 w-2 rounded-full bg-muted animate-pulse"></div> <div className="bg-muted h-2 w-2 animate-pulse rounded-full"></div>
<span>Checking for updates...</span> <span>Checking for updates...</span>
</> </>
) : comparisonData?.error ? ( ) : comparisonData?.error ? (
<> <>
<div className="h-2 w-2 rounded-full bg-destructive"></div> <div className="bg-destructive h-2 w-2 rounded-full"></div>
<span className="text-destructive">Error: {comparisonData.error}</span> <span className="text-destructive">
Error: {comparisonData.error}
</span>
</> </>
) : ( ) : (
<> <>
<div className="h-2 w-2 rounded-full bg-muted"></div> <div className="bg-muted h-2 w-2 rounded-full"></div>
<span>Status: Unknown</span> <span>Status: Unknown</span>
</> </>
)} )}
<button <button
onClick={() => void refetchComparison()} onClick={() => void refetchComparison()}
disabled={comparisonLoading} disabled={comparisonLoading}
className="ml-2 p-1.5 rounded-md hover:bg-accent transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center" className="hover:bg-accent ml-2 flex items-center justify-center rounded-md p-1.5 transition-colors disabled:cursor-not-allowed disabled:opacity-50"
title="Refresh comparison" title="Refresh comparison"
> >
{comparisonLoading ? ( {comparisonLoading ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></div> <div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></div>
) : ( ) : (
<svg <svg
className="h-4 w-4 text-muted-foreground hover:text-foreground" className="text-muted-foreground hover:text-foreground h-4 w-4"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -609,7 +629,7 @@ export function ScriptDetailModal({
)} )}
</div> </div>
{scriptFilesData.files.length > 0 && ( {scriptFilesData.files.length > 0 && (
<div className="mt-2 text-xs text-muted-foreground break-words"> <div className="text-muted-foreground mt-2 text-xs break-words">
Files: {scriptFilesData.files.join(", ")} Files: {scriptFilesData.files.join(", ")}
</div> </div>
)} )}
@@ -619,17 +639,17 @@ export function ScriptDetailModal({
{/* Load Message */} {/* Load Message */}
{loadMessage && ( {loadMessage && (
<div className="mb-4 rounded-lg bg-primary/10 p-3 text-sm text-primary"> <div className="bg-primary/10 text-primary mb-4 rounded-lg p-3 text-sm">
{loadMessage} {loadMessage}
</div> </div>
)} )}
{/* Description */} {/* Description */}
<div> <div>
<h3 className="mb-2 text-base sm:text-lg font-semibold text-foreground"> <h3 className="text-foreground mb-2 text-base font-semibold sm:text-lg">
Description Description
</h3> </h3>
<p className="text-sm sm:text-base text-muted-foreground"> <p className="text-muted-foreground text-sm sm:text-base">
{script.description} {script.description}
</p> </p>
</div> </div>
@@ -637,50 +657,50 @@ export function ScriptDetailModal({
{/* Basic Information */} {/* Basic Information */}
<div className="grid grid-cols-1 gap-4 sm:gap-6 lg:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:gap-6 lg:grid-cols-2">
<div> <div>
<h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground"> <h3 className="text-foreground mb-3 text-base font-semibold sm:text-lg">
Basic Information Basic Information
</h3> </h3>
<dl className="space-y-2"> <dl className="space-y-2">
<div> <div>
<dt className="text-sm font-medium text-muted-foreground"> <dt className="text-muted-foreground text-sm font-medium">
Slug Slug
</dt> </dt>
<dd className="font-mono text-sm text-foreground"> <dd className="text-foreground font-mono text-sm">
{script.slug} {script.slug}
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-sm font-medium text-muted-foreground"> <dt className="text-muted-foreground text-sm font-medium">
Date Created Date Created
</dt> </dt>
<dd className="text-sm text-foreground"> <dd className="text-foreground text-sm">
{script.date_created} {script.date_created}
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-sm font-medium text-muted-foreground"> <dt className="text-muted-foreground text-sm font-medium">
Categories Categories
</dt> </dt>
<dd className="text-sm text-foreground"> <dd className="text-foreground text-sm">
{script.categories.join(", ")} {script.categories.join(", ")}
</dd> </dd>
</div> </div>
{script.interface_port && ( {script.interface_port && (
<div> <div>
<dt className="text-sm font-medium text-muted-foreground"> <dt className="text-muted-foreground text-sm font-medium">
Interface Port Interface Port
</dt> </dt>
<dd className="text-sm text-foreground"> <dd className="text-foreground text-sm">
{script.interface_port} {script.interface_port}
</dd> </dd>
</div> </div>
)} )}
{script.config_path && ( {script.config_path && (
<div> <div>
<dt className="text-sm font-medium text-muted-foreground"> <dt className="text-muted-foreground text-sm font-medium">
Config Path Config Path
</dt> </dt>
<dd className="font-mono text-sm text-foreground"> <dd className="text-foreground font-mono text-sm">
{script.config_path} {script.config_path}
</dd> </dd>
</div> </div>
@@ -689,13 +709,13 @@ export function ScriptDetailModal({
</div> </div>
<div> <div>
<h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground"> <h3 className="text-foreground mb-3 text-base font-semibold sm:text-lg">
Links Links
</h3> </h3>
<dl className="space-y-2"> <dl className="space-y-2">
{script.website && ( {script.website && (
<div> <div>
<dt className="text-sm font-medium text-muted-foreground"> <dt className="text-muted-foreground text-sm font-medium">
Website Website
</dt> </dt>
<dd className="text-sm"> <dd className="text-sm">
@@ -703,7 +723,7 @@ export function ScriptDetailModal({
href={script.website} href={script.website}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="break-all text-primary hover:text-primary/80" className="text-primary hover:text-primary/80 break-all"
> >
{script.website} {script.website}
</a> </a>
@@ -712,7 +732,7 @@ export function ScriptDetailModal({
)} )}
{script.documentation && ( {script.documentation && (
<div> <div>
<dt className="text-sm font-medium text-muted-foreground"> <dt className="text-muted-foreground text-sm font-medium">
Documentation Documentation
</dt> </dt>
<dd className="text-sm"> <dd className="text-sm">
@@ -720,7 +740,7 @@ export function ScriptDetailModal({
href={script.documentation} href={script.documentation}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="break-all text-primary hover:text-primary/80" className="text-primary hover:text-primary/80 break-all"
> >
{script.documentation} {script.documentation}
</a> </a>
@@ -736,26 +756,26 @@ export function ScriptDetailModal({
script.type !== "pve" && script.type !== "pve" &&
script.type !== "addon" && ( script.type !== "addon" && (
<div> <div>
<h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground"> <h3 className="text-foreground mb-3 text-base font-semibold sm:text-lg">
Install Methods Install Methods
</h3> </h3>
<div className="space-y-4"> <div className="space-y-4">
{script.install_methods.map((method, index) => ( {script.install_methods.map((method, index) => (
<div <div
key={index} key={index}
className="rounded-lg border border-border bg-card p-3 sm:p-4" className="border-border bg-card rounded-lg border p-3 sm:p-4"
> >
<div className="mb-3 flex flex-col sm:flex-row sm:items-center justify-between space-y-1 sm:space-y-0"> <div className="mb-3 flex flex-col justify-between space-y-1 sm:flex-row sm:items-center sm:space-y-0">
<h4 className="text-sm sm:text-base font-medium text-foreground capitalize"> <h4 className="text-foreground text-sm font-medium capitalize sm:text-base">
{method.type} {method.type}
</h4> </h4>
<span className="font-mono text-xs sm:text-sm text-muted-foreground break-all"> <span className="text-muted-foreground font-mono text-xs break-all sm:text-sm">
{method.script} {method.script}
</span> </span>
</div> </div>
<div className="grid grid-cols-2 gap-2 sm:gap-4 text-xs sm:text-sm lg:grid-cols-4"> <div className="grid grid-cols-2 gap-2 text-xs sm:gap-4 sm:text-sm lg:grid-cols-4">
<div> <div>
<dt className="font-medium text-muted-foreground"> <dt className="text-muted-foreground font-medium">
CPU CPU
</dt> </dt>
<dd className="text-foreground"> <dd className="text-foreground">
@@ -763,7 +783,7 @@ export function ScriptDetailModal({
</dd> </dd>
</div> </div>
<div> <div>
<dt className="font-medium text-muted-foreground"> <dt className="text-muted-foreground font-medium">
RAM RAM
</dt> </dt>
<dd className="text-foreground"> <dd className="text-foreground">
@@ -771,7 +791,7 @@ export function ScriptDetailModal({
</dd> </dd>
</div> </div>
<div> <div>
<dt className="font-medium text-muted-foreground"> <dt className="text-muted-foreground font-medium">
HDD HDD
</dt> </dt>
<dd className="text-foreground"> <dd className="text-foreground">
@@ -779,7 +799,7 @@ export function ScriptDetailModal({
</dd> </dd>
</div> </div>
<div> <div>
<dt className="font-medium text-muted-foreground"> <dt className="text-muted-foreground font-medium">
OS OS
</dt> </dt>
<dd className="text-foreground"> <dd className="text-foreground">
@@ -797,26 +817,26 @@ export function ScriptDetailModal({
{(script.default_credentials.username ?? {(script.default_credentials.username ??
script.default_credentials.password) && ( script.default_credentials.password) && (
<div> <div>
<h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground"> <h3 className="text-foreground mb-3 text-base font-semibold sm:text-lg">
Default Credentials Default Credentials
</h3> </h3>
<dl className="space-y-2"> <dl className="space-y-2">
{script.default_credentials.username && ( {script.default_credentials.username && (
<div> <div>
<dt className="text-sm font-medium text-muted-foreground"> <dt className="text-muted-foreground text-sm font-medium">
Username Username
</dt> </dt>
<dd className="font-mono text-sm text-foreground"> <dd className="text-foreground font-mono text-sm">
{script.default_credentials.username} {script.default_credentials.username}
</dd> </dd>
</div> </div>
)} )}
{script.default_credentials.password && ( {script.default_credentials.password && (
<div> <div>
<dt className="text-sm font-medium text-muted-foreground"> <dt className="text-muted-foreground text-sm font-medium">
Password Password
</dt> </dt>
<dd className="font-mono text-sm text-foreground"> <dd className="text-foreground font-mono text-sm">
{script.default_credentials.password} {script.default_credentials.password}
</dd> </dd>
</div> </div>
@@ -828,7 +848,7 @@ export function ScriptDetailModal({
{/* Notes */} {/* Notes */}
{script.notes.length > 0 && ( {script.notes.length > 0 && (
<div> <div>
<h3 className="mb-3 text-lg font-semibold text-foreground"> <h3 className="text-foreground mb-3 text-lg font-semibold">
Notes Notes
</h3> </h3>
<ul className="space-y-2"> <ul className="space-y-2">
@@ -843,14 +863,17 @@ export function ScriptDetailModal({
key={index} key={index}
className={`rounded-lg p-3 text-sm ${ className={`rounded-lg p-3 text-sm ${
noteType === "warning" noteType === "warning"
? "border-l-4 border-warning bg-warning/10 text-warning" ? "border-warning bg-warning/10 text-warning border-l-4"
: noteType === "error" : noteType === "error"
? "border-l-4 border-destructive bg-destructive/10 text-destructive" ? "border-destructive bg-destructive/10 text-destructive border-l-4"
: "bg-muted text-muted-foreground" : "bg-muted text-muted-foreground"
}`} }`}
> >
<div className="flex items-start"> <div className="flex items-start">
<NoteBadge noteType={noteType as 'info' | 'warning' | 'error'} className="mr-2 flex-shrink-0"> <NoteBadge
noteType={noteType as "info" | "warning" | "error"}
className="mr-2 flex-shrink-0"
>
{noteType} {noteType}
</NoteBadge> </NoteBadge>
<span>{noteText}</span> <span>{noteText}</span>
@@ -882,7 +905,13 @@ export function ScriptDetailModal({
<TextViewer <TextViewer
scriptName={ scriptName={
script.install_methods script.install_methods
?.find((method) => method.script && (method.script.startsWith("ct/") || method.script.startsWith("vm/") || method.script.startsWith("tools/"))) ?.find(
(method) =>
method.script &&
(method.script.startsWith("ct/") ||
method.script.startsWith("vm/") ||
method.script.startsWith("tools/")),
)
?.script?.split("/") ?.script?.split("/")
.pop() ?? `${script.slug}.sh` .pop() ?? `${script.slug}.sh`
} }

View File

@@ -1,9 +1,9 @@
'use client'; "use client";
import { useState } from 'react'; import { useState } from "react";
import type { Script, ScriptInstallMethod } from '../../types/script'; import type { Script } from "../../types/script";
import { Button } from './ui/button'; import { Button } from "./ui/button";
import { useRegisterModal } from './modal/ModalStackProvider'; import { useRegisterModal } from "./modal/ModalStackProvider";
interface ScriptVersionModalProps { interface ScriptVersionModalProps {
isOpen: boolean; isOpen: boolean;
@@ -12,16 +12,29 @@ interface ScriptVersionModalProps {
script: Script | null; script: Script | null;
} }
export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }: ScriptVersionModalProps) { export function ScriptVersionModal({
useRegisterModal(isOpen, { id: 'script-version-modal', allowEscape: true, onClose }); isOpen,
onClose,
onSelectVersion,
script,
}: ScriptVersionModalProps) {
useRegisterModal(isOpen, {
id: "script-version-modal",
allowEscape: true,
onClose,
});
const [selectedVersion, setSelectedVersion] = useState<string | null>(null); const [selectedVersion, setSelectedVersion] = useState<string | null>(null);
if (!isOpen || !script) return null; if (!isOpen || !script) return null;
// Get available install methods // Get available install methods
const installMethods = script.install_methods || []; const installMethods = script.install_methods || [];
const defaultMethod = installMethods.find(method => method.type === 'default'); const defaultMethod = installMethods.find(
const alpineMethod = installMethods.find(method => method.type === 'alpine'); (method) => method.type === "default",
);
const alpineMethod = installMethods.find(
(method) => method.type === "alpine",
);
const handleConfirm = () => { const handleConfirm = () => {
if (selectedVersion) { if (selectedVersion) {
@@ -35,19 +48,29 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
}; };
return ( return (
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<div className="bg-card rounded-lg shadow-xl max-w-2xl w-full border border-border"> <div className="bg-card border-border w-full max-w-2xl rounded-lg border shadow-xl">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border"> <div className="border-border flex items-center justify-between border-b p-6">
<h2 className="text-xl font-bold text-foreground">Select Version</h2> <h2 className="text-foreground text-xl font-bold">Select Version</h2>
<Button <Button
onClick={onClose} onClick={onClose}
variant="ghost" variant="ghost"
size="icon" size="icon"
className="text-muted-foreground hover:text-foreground" className="text-muted-foreground hover:text-foreground"
> >
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> className="h-6 w-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg> </svg>
</Button> </Button>
</div> </div>
@@ -55,11 +78,12 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
{/* Content */} {/* Content */}
<div className="p-6"> <div className="p-6">
<div className="mb-6"> <div className="mb-6">
<h3 className="text-lg font-medium text-foreground mb-2"> <h3 className="text-foreground mb-2 text-lg font-medium">
Choose a version for &quot;{script.name}&quot; Choose a version for &quot;{script.name}&quot;
</h3> </h3>
<p className="text-sm text-muted-foreground"> <p className="text-muted-foreground text-sm">
Select the version you want to install. Each version has different resource requirements. Select the version you want to install. Each version has different
resource requirements.
</p> </p>
</div> </div>
@@ -67,25 +91,29 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
{/* Default Version */} {/* Default Version */}
{defaultMethod && ( {defaultMethod && (
<div <div
onClick={() => handleVersionSelect('default')} onClick={() => handleVersionSelect("default")}
className={`cursor-pointer rounded-lg border-2 p-4 transition-all ${ className={`cursor-pointer rounded-lg border-2 p-4 transition-all ${
selectedVersion === 'default' selectedVersion === "default"
? 'border-primary bg-primary/10' ? "border-primary bg-primary/10"
: 'border-border bg-card hover:border-primary/50' : "border-border bg-card hover:border-primary/50"
}`} }`}
> >
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center space-x-3 mb-3"> <div className="mb-3 flex items-center space-x-3">
<div <div
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${ className={`flex h-5 w-5 items-center justify-center rounded-full border-2 ${
selectedVersion === 'default' selectedVersion === "default"
? 'border-primary bg-primary' ? "border-primary bg-primary"
: 'border-border' : "border-border"
}`} }`}
> >
{selectedVersion === 'default' && ( {selectedVersion === "default" && (
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20"> <svg
className="h-3 w-3 text-white"
fill="currentColor"
viewBox="0 0 20 20"
>
<path <path
fillRule="evenodd" 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" 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"
@@ -94,27 +122,34 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
</svg> </svg>
)} )}
</div> </div>
<h4 className="text-base font-semibold text-foreground capitalize"> <h4 className="text-foreground text-base font-semibold capitalize">
{defaultMethod.type} {defaultMethod.type}
</h4> </h4>
</div> </div>
<div className="grid grid-cols-2 gap-3 text-sm ml-8"> <div className="ml-8 grid grid-cols-2 gap-3 text-sm">
<div> <div>
<span className="text-muted-foreground">CPU: </span> <span className="text-muted-foreground">CPU: </span>
<span className="text-foreground font-medium">{defaultMethod.resources.cpu} cores</span> <span className="text-foreground font-medium">
{defaultMethod.resources.cpu} cores
</span>
</div> </div>
<div> <div>
<span className="text-muted-foreground">RAM: </span> <span className="text-muted-foreground">RAM: </span>
<span className="text-foreground font-medium">{defaultMethod.resources.ram} MB</span> <span className="text-foreground font-medium">
{defaultMethod.resources.ram} MB
</span>
</div> </div>
<div> <div>
<span className="text-muted-foreground">HDD: </span> <span className="text-muted-foreground">HDD: </span>
<span className="text-foreground font-medium">{defaultMethod.resources.hdd} GB</span> <span className="text-foreground font-medium">
{defaultMethod.resources.hdd} GB
</span>
</div> </div>
<div> <div>
<span className="text-muted-foreground">OS: </span> <span className="text-muted-foreground">OS: </span>
<span className="text-foreground font-medium"> <span className="text-foreground font-medium">
{defaultMethod.resources.os} {defaultMethod.resources.version} {defaultMethod.resources.os}{" "}
{defaultMethod.resources.version}
</span> </span>
</div> </div>
</div> </div>
@@ -126,25 +161,29 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
{/* Alpine Version */} {/* Alpine Version */}
{alpineMethod && ( {alpineMethod && (
<div <div
onClick={() => handleVersionSelect('alpine')} onClick={() => handleVersionSelect("alpine")}
className={`cursor-pointer rounded-lg border-2 p-4 transition-all ${ className={`cursor-pointer rounded-lg border-2 p-4 transition-all ${
selectedVersion === 'alpine' selectedVersion === "alpine"
? 'border-primary bg-primary/10' ? "border-primary bg-primary/10"
: 'border-border bg-card hover:border-primary/50' : "border-border bg-card hover:border-primary/50"
}`} }`}
> >
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center space-x-3 mb-3"> <div className="mb-3 flex items-center space-x-3">
<div <div
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${ className={`flex h-5 w-5 items-center justify-center rounded-full border-2 ${
selectedVersion === 'alpine' selectedVersion === "alpine"
? 'border-primary bg-primary' ? "border-primary bg-primary"
: 'border-border' : "border-border"
}`} }`}
> >
{selectedVersion === 'alpine' && ( {selectedVersion === "alpine" && (
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20"> <svg
className="h-3 w-3 text-white"
fill="currentColor"
viewBox="0 0 20 20"
>
<path <path
fillRule="evenodd" 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" 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"
@@ -153,27 +192,34 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
</svg> </svg>
)} )}
</div> </div>
<h4 className="text-base font-semibold text-foreground capitalize"> <h4 className="text-foreground text-base font-semibold capitalize">
{alpineMethod.type} {alpineMethod.type}
</h4> </h4>
</div> </div>
<div className="grid grid-cols-2 gap-3 text-sm ml-8"> <div className="ml-8 grid grid-cols-2 gap-3 text-sm">
<div> <div>
<span className="text-muted-foreground">CPU: </span> <span className="text-muted-foreground">CPU: </span>
<span className="text-foreground font-medium">{alpineMethod.resources.cpu} cores</span> <span className="text-foreground font-medium">
{alpineMethod.resources.cpu} cores
</span>
</div> </div>
<div> <div>
<span className="text-muted-foreground">RAM: </span> <span className="text-muted-foreground">RAM: </span>
<span className="text-foreground font-medium">{alpineMethod.resources.ram} MB</span> <span className="text-foreground font-medium">
{alpineMethod.resources.ram} MB
</span>
</div> </div>
<div> <div>
<span className="text-muted-foreground">HDD: </span> <span className="text-muted-foreground">HDD: </span>
<span className="text-foreground font-medium">{alpineMethod.resources.hdd} GB</span> <span className="text-foreground font-medium">
{alpineMethod.resources.hdd} GB
</span>
</div> </div>
<div> <div>
<span className="text-muted-foreground">OS: </span> <span className="text-muted-foreground">OS: </span>
<span className="text-foreground font-medium"> <span className="text-foreground font-medium">
{alpineMethod.resources.os} {alpineMethod.resources.version} {alpineMethod.resources.os}{" "}
{alpineMethod.resources.version}
</span> </span>
</div> </div>
</div> </div>
@@ -184,12 +230,8 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
</div> </div>
{/* Action Buttons */} {/* Action Buttons */}
<div className="flex justify-end space-x-3 mt-6"> <div className="mt-6 flex justify-end space-x-3">
<Button <Button onClick={onClose} variant="outline" size="default">
onClick={onClose}
variant="outline"
size="default"
>
Cancel Cancel
</Button> </Button>
<Button <Button
@@ -197,7 +239,9 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
disabled={!selectedVersion} disabled={!selectedVersion}
variant="default" variant="default"
size="default" size="default"
className={!selectedVersion ? 'bg-muted-foreground cursor-not-allowed' : ''} className={
!selectedVersion ? "bg-muted-foreground cursor-not-allowed" : ""
}
> >
Continue Continue
</Button> </Button>
@@ -207,4 +251,3 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
</div> </div>
); );
} }

View File

@@ -1,11 +1,11 @@
'use client'; "use client";
import { useState, useEffect } from 'react'; import { useState, useEffect } from "react";
import type { CreateServerData } from '../../types/server'; import type { CreateServerData } from "../../types/server";
import { Button } from './ui/button'; import { Button } from "./ui/button";
import { SSHKeyInput } from './SSHKeyInput'; import { SSHKeyInput } from "./SSHKeyInput";
import { PublicKeyModal } from './PublicKeyModal'; import { PublicKeyModal } from "./PublicKeyModal";
import { Key } from 'lucide-react'; import { Key } from "lucide-react";
interface ServerFormProps { interface ServerFormProps {
onSubmit: (data: CreateServerData) => void; onSubmit: (data: CreateServerData) => void;
@@ -14,40 +14,47 @@ interface ServerFormProps {
onCancel?: () => void; onCancel?: () => void;
} }
export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel }: ServerFormProps) { export function ServerForm({
onSubmit,
initialData,
isEditing = false,
onCancel,
}: ServerFormProps) {
const [formData, setFormData] = useState<CreateServerData>( const [formData, setFormData] = useState<CreateServerData>(
initialData ?? { initialData ?? {
name: '', name: "",
ip: '', ip: "",
user: '', user: "",
password: '', password: "",
auth_type: 'password', auth_type: "password",
ssh_key: '', ssh_key: "",
ssh_key_passphrase: '', ssh_key_passphrase: "",
ssh_port: 22, ssh_port: 22,
color: '#3b82f6', color: "#3b82f6",
} },
); );
const [errors, setErrors] = useState<Partial<Record<keyof CreateServerData, string>>>({}); const [errors, setErrors] = useState<
const [sshKeyError, setSshKeyError] = useState<string>(''); Partial<Record<keyof CreateServerData, string>>
>({});
const [sshKeyError, setSshKeyError] = useState<string>("");
const [colorCodingEnabled, setColorCodingEnabled] = useState(false); const [colorCodingEnabled, setColorCodingEnabled] = useState(false);
const [isGeneratingKey, setIsGeneratingKey] = useState(false); const [isGeneratingKey, setIsGeneratingKey] = useState(false);
const [showPublicKeyModal, setShowPublicKeyModal] = useState(false); const [showPublicKeyModal, setShowPublicKeyModal] = useState(false);
const [generatedPublicKey, setGeneratedPublicKey] = useState(''); const [generatedPublicKey, setGeneratedPublicKey] = useState("");
const [, setIsGeneratedKey] = useState(false); const [, setIsGeneratedKey] = useState(false);
const [, setGeneratedServerId] = useState<number | null>(null); const [, setGeneratedServerId] = useState<number | null>(null);
useEffect(() => { useEffect(() => {
const loadColorCodingSetting = async () => { const loadColorCodingSetting = async () => {
try { try {
const response = await fetch('/api/settings/color-coding'); const response = await fetch("/api/settings/color-coding");
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
setColorCodingEnabled(Boolean(data.enabled)); setColorCodingEnabled(Boolean(data.enabled));
} }
} catch (error) { } catch (error) {
console.error('Error loading color coding setting:', error); console.error("Error loading color coding setting:", error);
} }
}; };
void loadColorCodingSetting(); void loadColorCodingSetting();
@@ -58,14 +65,15 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
if (!trimmed) return false; if (!trimmed) return false;
// IPv4 validation // IPv4 validation
const ipv4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; const ipv4Regex =
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
if (ipv4Regex.test(trimmed)) { if (ipv4Regex.test(trimmed)) {
return true; return true;
} }
// Check for IPv6 with zone identifier (link-local addresses like fe80::...%eth0) // Check for IPv6 with zone identifier (link-local addresses like fe80::...%eth0)
let ipv6Address = trimmed; let ipv6Address = trimmed;
const zoneIdMatch = trimmed.match(/^(.+)%([a-zA-Z0-9_\-]+)$/); const zoneIdMatch = /^(.+)%([a-zA-Z0-9_\-]+)$/.exec(trimmed);
if (zoneIdMatch) { if (zoneIdMatch) {
ipv6Address = zoneIdMatch[1]; ipv6Address = zoneIdMatch[1];
// Zone identifier should be a valid interface name (alphanumeric, underscore, hyphen) // Zone identifier should be a valid interface name (alphanumeric, underscore, hyphen)
@@ -79,10 +87,11 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
// Matches: 2001:0db8:85a3:0000:0000:8a2e:0370:7334, ::1, 2001:db8::1, etc. // Matches: 2001:0db8:85a3:0000:0000:8a2e:0370:7334, ::1, 2001:db8::1, etc.
// Also supports IPv4-mapped IPv6 addresses like ::ffff:192.168.1.1 // Also supports IPv4-mapped IPv6 addresses like ::ffff:192.168.1.1
// Simplified validation: check for valid hex segments separated by colons // Simplified validation: check for valid hex segments separated by colons
const ipv6Pattern = /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::1$|^::$|^(?:[0-9a-fA-F]{1,4}:)*::(?:[0-9a-fA-F]{1,4}:)*[0-9a-fA-F]{1,4}$|^(?:[0-9a-fA-F]{1,4}:)*::[0-9a-fA-F]{1,4}$|^::(?:[0-9a-fA-F]{1,4}:)+[0-9a-fA-F]{1,4}$|^::ffff:(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(?:[0-9a-fA-F]{1,4}:){1,4}:(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; const ipv6Pattern =
/^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::1$|^::$|^(?:[0-9a-fA-F]{1,4}:)*::(?:[0-9a-fA-F]{1,4}:)*[0-9a-fA-F]{1,4}$|^(?:[0-9a-fA-F]{1,4}:)*::[0-9a-fA-F]{1,4}$|^::(?:[0-9a-fA-F]{1,4}:)+[0-9a-fA-F]{1,4}$|^::ffff:(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(?:[0-9a-fA-F]{1,4}:){1,4}:(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
if (ipv6Pattern.test(ipv6Address)) { if (ipv6Pattern.test(ipv6Address)) {
// Additional validation: ensure only one :: compression exists // Additional validation: ensure only one :: compression exists
const compressionCount = (ipv6Address.match(/::/g) || []).length; const compressionCount = (ipv6Address.match(/::/g) ?? []).length;
if (compressionCount <= 1) { if (compressionCount <= 1) {
return true; return true;
} }
@@ -91,17 +100,19 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
// FQDN/hostname validation (RFC 1123 compliant) // FQDN/hostname validation (RFC 1123 compliant)
// Allows letters, numbers, hyphens, dots; must start and end with alphanumeric // Allows letters, numbers, hyphens, dots; must start and end with alphanumeric
// Max length 253 characters, each label max 63 characters // Max length 253 characters, each label max 63 characters
const hostnameRegex = /^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$/; const hostnameRegex =
/^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$/;
if (hostnameRegex.test(trimmed) && trimmed.length <= 253) { if (hostnameRegex.test(trimmed) && trimmed.length <= 253) {
// Additional check: each label (between dots) must be max 63 chars // Additional check: each label (between dots) must be max 63 chars
const labels = trimmed.split('.'); const labels = trimmed.split(".");
if (labels.every(label => label.length > 0 && label.length <= 63)) { if (labels.every((label) => label.length > 0 && label.length <= 63)) {
return true; return true;
} }
} }
// Also allow simple hostnames without dots (like 'localhost') // Also allow simple hostnames without dots (like 'localhost')
const simpleHostnameRegex = /^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$/; const simpleHostnameRegex =
/^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$/;
if (simpleHostnameRegex.test(trimmed) && trimmed.length <= 63) { if (simpleHostnameRegex.test(trimmed) && trimmed.length <= 63) {
return true; return true;
} }
@@ -113,42 +124,45 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
const newErrors: Partial<Record<keyof CreateServerData, string>> = {}; const newErrors: Partial<Record<keyof CreateServerData, string>> = {};
if (!formData.name.trim()) { if (!formData.name.trim()) {
newErrors.name = 'Server name is required'; newErrors.name = "Server name is required";
} }
if (!formData.ip.trim()) { if (!formData.ip.trim()) {
newErrors.ip = 'Server address is required'; newErrors.ip = "Server address is required";
} else { } else {
if (!validateServerAddress(formData.ip)) { if (!validateServerAddress(formData.ip)) {
newErrors.ip = 'Please enter a valid IP address (IPv4/IPv6) or hostname'; newErrors.ip =
"Please enter a valid IP address (IPv4/IPv6) or hostname";
} }
} }
if (!formData.user.trim()) { if (!formData.user.trim()) {
newErrors.user = 'Username is required'; newErrors.user = "Username is required";
} }
// Validate SSH port // Validate SSH port
if (formData.ssh_port !== undefined && (formData.ssh_port < 1 || formData.ssh_port > 65535)) { if (
newErrors.ssh_port = 'SSH port must be between 1 and 65535'; formData.ssh_port !== undefined &&
(formData.ssh_port < 1 || formData.ssh_port > 65535)
) {
newErrors.ssh_port = "SSH port must be between 1 and 65535";
} }
// Validate authentication based on auth_type // Validate authentication based on auth_type
const authType = formData.auth_type ?? 'password'; const authType = formData.auth_type ?? "password";
if (authType === 'password') { if (authType === "password") {
if (!formData.password?.trim()) { if (!formData.password?.trim()) {
newErrors.password = 'Password is required for password authentication'; newErrors.password = "Password is required for password authentication";
} }
} }
if (authType === 'key') { if (authType === "key") {
if (!formData.ssh_key?.trim()) { if (!formData.ssh_key?.trim()) {
newErrors.ssh_key = 'SSH key is required for key authentication'; newErrors.ssh_key = "SSH key is required for key authentication";
} }
} }
setErrors(newErrors); setErrors(newErrors);
return Object.keys(newErrors).length === 0 && !sshKeyError; return Object.keys(newErrors).length === 0 && !sshKeyError;
}; };
@@ -159,156 +173,185 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
onSubmit(formData); onSubmit(formData);
if (!isEditing) { if (!isEditing) {
setFormData({ setFormData({
name: '', name: "",
ip: '', ip: "",
user: '', user: "",
password: '', password: "",
auth_type: 'password', auth_type: "password",
ssh_key: '', ssh_key: "",
ssh_key_passphrase: '', ssh_key_passphrase: "",
ssh_port: 22, ssh_port: 22,
color: '#3b82f6' color: "#3b82f6",
}); });
} }
} }
}; };
const handleChange = (field: keyof CreateServerData) => ( const handleChange =
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement> (field: keyof CreateServerData) =>
) => { (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
// Special handling for numeric ssh_port: keep it strictly numeric // Special handling for numeric ssh_port: keep it strictly numeric
if (field === 'ssh_port') { if (field === "ssh_port") {
const raw = (e.target as HTMLInputElement).value ?? ''; const raw = (e.target as HTMLInputElement).value ?? "";
const digitsOnly = raw.replace(/\D+/g, ''); const digitsOnly = raw.replace(/\D+/g, "");
setFormData(prev => ({ setFormData((prev) => ({
...prev, ...prev,
ssh_port: digitsOnly ? parseInt(digitsOnly, 10) : undefined, ssh_port: digitsOnly ? parseInt(digitsOnly, 10) : undefined,
})); }));
if (errors.ssh_port) { if (errors.ssh_port) {
setErrors(prev => ({ ...prev, ssh_port: undefined })); setErrors((prev) => ({ ...prev, ssh_port: undefined }));
} }
return; return;
} }
setFormData(prev => ({ ...prev, [field]: (e.target as HTMLInputElement).value })); setFormData((prev) => ({
...prev,
[field]: (e.target as HTMLInputElement).value,
}));
// Clear error when user starts typing // Clear error when user starts typing
if (errors[field]) { if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: undefined })); setErrors((prev) => ({ ...prev, [field]: undefined }));
} }
// Reset generated key state when switching auth types // Reset generated key state when switching auth types
if (field === 'auth_type') { if (field === "auth_type") {
setIsGeneratedKey(false); setIsGeneratedKey(false);
setGeneratedPublicKey(''); setGeneratedPublicKey("");
} }
}; };
const handleGenerateKeyPair = async () => { const handleGenerateKeyPair = async () => {
setIsGeneratingKey(true); setIsGeneratingKey(true);
try { try {
const response = await fetch('/api/servers/generate-keypair', { const response = await fetch("/api/servers/generate-keypair", {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
}, },
}); });
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to generate key pair'); throw new Error("Failed to generate key pair");
} }
const data = await response.json() as { success: boolean; privateKey?: string; publicKey?: string; serverId?: number; error?: string }; const data = (await response.json()) as {
success: boolean;
privateKey?: string;
publicKey?: string;
serverId?: number;
error?: string;
};
if (data.success) { if (data.success) {
const serverId = data.serverId ?? 0; const serverId = data.serverId ?? 0;
const keyPath = `data/ssh-keys/server_${serverId}_key`; const keyPath = `data/ssh-keys/server_${serverId}_key`;
setFormData(prev => ({ setFormData((prev) => ({
...prev, ...prev,
ssh_key: data.privateKey ?? '', ssh_key: data.privateKey ?? "",
ssh_key_path: keyPath, ssh_key_path: keyPath,
key_generated: true key_generated: true,
})); }));
setGeneratedPublicKey(data.publicKey ?? ''); setGeneratedPublicKey(data.publicKey ?? "");
setGeneratedServerId(serverId); setGeneratedServerId(serverId);
setIsGeneratedKey(true); setIsGeneratedKey(true);
setShowPublicKeyModal(true); setShowPublicKeyModal(true);
setSshKeyError(''); setSshKeyError("");
} else { } else {
throw new Error(data.error ?? 'Failed to generate key pair'); throw new Error(data.error ?? "Failed to generate key pair");
} }
} catch (error) { } catch (error) {
console.error('Error generating key pair:', error); console.error("Error generating key pair:", error);
setSshKeyError(error instanceof Error ? error.message : 'Failed to generate key pair'); setSshKeyError(
error instanceof Error ? error.message : "Failed to generate key pair",
);
} finally { } finally {
setIsGeneratingKey(false); setIsGeneratingKey(false);
} }
}; };
const handleSSHKeyChange = (value: string) => { const handleSSHKeyChange = (value: string) => {
setFormData(prev => ({ ...prev, ssh_key: value })); setFormData((prev) => ({ ...prev, ssh_key: value }));
if (errors.ssh_key) { if (errors.ssh_key) {
setErrors(prev => ({ ...prev, ssh_key: undefined })); setErrors((prev) => ({ ...prev, ssh_key: undefined }));
} }
}; };
return ( return (
<> <>
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div> <div>
<label htmlFor="name" className="block text-sm font-medium text-muted-foreground mb-1"> <label
htmlFor="name"
className="text-muted-foreground mb-1 block text-sm font-medium"
>
Server Name * Server Name *
</label> </label>
<input <input
type="text" type="text"
id="name" id="name"
value={formData.name} value={formData.name}
onChange={handleChange('name')} onChange={handleChange("name")}
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${ className={`bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none ${
errors.name ? 'border-destructive' : 'border-border' errors.name ? "border-destructive" : "border-border"
}`} }`}
placeholder="e.g., Production Server" placeholder="e.g., Production Server"
/> />
{errors.name && <p className="mt-1 text-sm text-destructive">{errors.name}</p>} {errors.name && (
<p className="text-destructive mt-1 text-sm">{errors.name}</p>
)}
</div> </div>
<div> <div>
<label htmlFor="ip" className="block text-sm font-medium text-muted-foreground mb-1"> <label
htmlFor="ip"
className="text-muted-foreground mb-1 block text-sm font-medium"
>
Host/IP Address * Host/IP Address *
</label> </label>
<input <input
type="text" type="text"
id="ip" id="ip"
value={formData.ip} value={formData.ip}
onChange={handleChange('ip')} onChange={handleChange("ip")}
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${ className={`bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none ${
errors.ip ? 'border-destructive' : 'border-border' errors.ip ? "border-destructive" : "border-border"
}`} }`}
placeholder="e.g., 192.168.1.100, server.example.com, 2001:db8::1, or fe80::...%eth0" placeholder="e.g., 192.168.1.100, server.example.com, 2001:db8::1, or fe80::...%eth0"
/> />
{errors.ip && <p className="mt-1 text-sm text-destructive">{errors.ip}</p>} {errors.ip && (
<p className="text-destructive mt-1 text-sm">{errors.ip}</p>
)}
</div> </div>
<div> <div>
<label htmlFor="user" className="block text-sm font-medium text-muted-foreground mb-1"> <label
htmlFor="user"
className="text-muted-foreground mb-1 block text-sm font-medium"
>
Username * Username *
</label> </label>
<input <input
type="text" type="text"
id="user" id="user"
value={formData.user} value={formData.user}
onChange={handleChange('user')} onChange={handleChange("user")}
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${ className={`bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none ${
errors.user ? 'border-destructive' : 'border-border' errors.user ? "border-destructive" : "border-border"
}`} }`}
placeholder="e.g., root" placeholder="e.g., root"
/> />
{errors.user && <p className="mt-1 text-sm text-destructive">{errors.user}</p>} {errors.user && (
<p className="text-destructive mt-1 text-sm">{errors.user}</p>
)}
</div> </div>
<div> <div>
<label htmlFor="ssh_port" className="block text-sm font-medium text-muted-foreground mb-1"> <label
htmlFor="ssh_port"
className="text-muted-foreground mb-1 block text-sm font-medium"
>
SSH Port SSH Port
</label> </label>
<input <input
@@ -318,26 +361,31 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
pattern="[0-9]*" pattern="[0-9]*"
autoComplete="off" autoComplete="off"
value={formData.ssh_port ?? 22} value={formData.ssh_port ?? 22}
onChange={handleChange('ssh_port')} onChange={handleChange("ssh_port")}
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${ className={`bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none ${
errors.ssh_port ? 'border-destructive' : 'border-border' errors.ssh_port ? "border-destructive" : "border-border"
}`} }`}
placeholder="22" placeholder="22"
min={1} min={1}
max={65535} max={65535}
/> />
{errors.ssh_port && <p className="mt-1 text-sm text-destructive">{errors.ssh_port}</p>} {errors.ssh_port && (
<p className="text-destructive mt-1 text-sm">{errors.ssh_port}</p>
)}
</div> </div>
<div> <div>
<label htmlFor="auth_type" className="block text-sm font-medium text-muted-foreground mb-1"> <label
htmlFor="auth_type"
className="text-muted-foreground mb-1 block text-sm font-medium"
>
Authentication Type * Authentication Type *
</label> </label>
<select <select
id="auth_type" id="auth_type"
value={formData.auth_type ?? 'password'} value={formData.auth_type ?? "password"}
onChange={handleChange('auth_type')} onChange={handleChange("auth_type")}
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring border-border" className="bg-card text-foreground focus:ring-ring focus:border-ring border-border w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none"
> >
<option value="password">Password Only</option> <option value="password">Password Only</option>
<option value="key">SSH Key Only</option> <option value="key">SSH Key Only</option>
@@ -346,18 +394,21 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
{colorCodingEnabled && ( {colorCodingEnabled && (
<div> <div>
<label htmlFor="color" className="block text-sm font-medium text-muted-foreground mb-1"> <label
htmlFor="color"
className="text-muted-foreground mb-1 block text-sm font-medium"
>
Server Color Server Color
</label> </label>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<input <input
type="color" type="color"
id="color" id="color"
value={formData.color ?? '#3b82f6'} value={formData.color ?? "#3b82f6"}
onChange={handleChange('color')} onChange={handleChange("color")}
className="w-20 h-10 rounded cursor-pointer border border-border" className="border-border h-10 w-20 cursor-pointer rounded border"
/> />
<span className="text-sm text-muted-foreground"> <span className="text-muted-foreground text-sm">
Choose a color to identify this server Choose a color to identify this server
</span> </span>
</div> </div>
@@ -366,31 +417,36 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
</div> </div>
{/* Password Authentication */} {/* Password Authentication */}
{formData.auth_type === 'password' && ( {formData.auth_type === "password" && (
<div> <div>
<label htmlFor="password" className="block text-sm font-medium text-muted-foreground mb-1"> <label
htmlFor="password"
className="text-muted-foreground mb-1 block text-sm font-medium"
>
Password * Password *
</label> </label>
<input <input
type="password" type="password"
id="password" id="password"
value={formData.password ?? ''} value={formData.password ?? ""}
onChange={handleChange('password')} onChange={handleChange("password")}
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${ className={`bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none ${
errors.password ? 'border-destructive' : 'border-border' errors.password ? "border-destructive" : "border-border"
}`} }`}
placeholder="Enter password" placeholder="Enter password"
/> />
{errors.password && <p className="mt-1 text-sm text-destructive">{errors.password}</p>} {errors.password && (
<p className="text-destructive mt-1 text-sm">{errors.password}</p>
)}
</div> </div>
)} )}
{/* SSH Key Authentication */} {/* SSH Key Authentication */}
{formData.auth_type === 'key' && ( {formData.auth_type === "key" && (
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<div className="flex items-center justify-between mb-1"> <div className="mb-1 flex items-center justify-between">
<label className="block text-sm font-medium text-muted-foreground"> <label className="text-muted-foreground block text-sm font-medium">
SSH Private Key * SSH Private Key *
</label> </label>
<Button <Button
@@ -402,7 +458,7 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
className="gap-2" className="gap-2"
> >
<Key className="h-4 w-4" /> <Key className="h-4 w-4" />
{isGeneratingKey ? 'Generating...' : 'Generate Key Pair'} {isGeneratingKey ? "Generating..." : "Generate Key Pair"}
</Button> </Button>
</div> </div>
@@ -410,24 +466,42 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
{!formData.key_generated && ( {!formData.key_generated && (
<> <>
<SSHKeyInput <SSHKeyInput
value={formData.ssh_key ?? ''} value={formData.ssh_key ?? ""}
onChange={handleSSHKeyChange} onChange={handleSSHKeyChange}
onError={setSshKeyError} onError={setSshKeyError}
/> />
{errors.ssh_key && <p className="mt-1 text-sm text-destructive">{errors.ssh_key}</p>} {errors.ssh_key && (
{sshKeyError && <p className="mt-1 text-sm text-destructive">{sshKeyError}</p>} <p className="text-destructive mt-1 text-sm">
{errors.ssh_key}
</p>
)}
{sshKeyError && (
<p className="text-destructive mt-1 text-sm">
{sshKeyError}
</p>
)}
</> </>
)} )}
{/* Show generated key status */} {/* Show generated key status */}
{formData.key_generated && ( {formData.key_generated && (
<div className="p-3 bg-success/10 border border-success/20 rounded-md"> <div className="bg-success/10 border-success/20 rounded-md border p-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<svg className="w-4 h-4 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> className="text-success h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg> </svg>
<span className="text-sm font-medium text-success-foreground"> <span className="text-success-foreground text-sm font-medium">
SSH key pair generated successfully SSH key pair generated successfully
</span> </span>
</div> </div>
@@ -436,46 +510,50 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setShowPublicKeyModal(true)} onClick={() => setShowPublicKeyModal(true)}
className="gap-2 border-info/20 text-info bg-info/10 hover:bg-info/20" className="border-info/20 text-info bg-info/10 hover:bg-info/20 gap-2"
> >
<Key className="h-4 w-4" /> <Key className="h-4 w-4" />
View Public Key View Public Key
</Button> </Button>
</div> </div>
<p className="text-xs text-success/80 mt-1"> <p className="text-success/80 mt-1 text-xs">
The private key has been generated and will be saved with the server. The private key has been generated and will be saved with
the server.
</p> </p>
</div> </div>
)} )}
</div> </div>
<div> <div>
<label htmlFor="ssh_key_passphrase" className="block text-sm font-medium text-muted-foreground mb-1"> <label
htmlFor="ssh_key_passphrase"
className="text-muted-foreground mb-1 block text-sm font-medium"
>
SSH Key Passphrase (Optional) SSH Key Passphrase (Optional)
</label> </label>
<input <input
type="password" type="password"
id="ssh_key_passphrase" id="ssh_key_passphrase"
value={formData.ssh_key_passphrase ?? ''} value={formData.ssh_key_passphrase ?? ""}
onChange={handleChange('ssh_key_passphrase')} onChange={handleChange("ssh_key_passphrase")}
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring border-border" className="bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring border-border w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none"
placeholder="Enter passphrase for encrypted key" placeholder="Enter passphrase for encrypted key"
/> />
<p className="mt-1 text-xs text-muted-foreground"> <p className="text-muted-foreground mt-1 text-xs">
Only required if your SSH key is encrypted with a passphrase Only required if your SSH key is encrypted with a passphrase
</p> </p>
</div> </div>
</div> </div>
)} )}
<div className="flex flex-col sm:flex-row justify-end space-y-2 sm:space-y-0 sm:space-x-3 pt-4"> <div className="flex flex-col justify-end space-y-2 pt-4 sm:flex-row sm:space-y-0 sm:space-x-3">
{isEditing && onCancel && ( {isEditing && onCancel && (
<Button <Button
type="button" type="button"
onClick={onCancel} onClick={onCancel}
variant="outline" variant="outline"
size="default" size="default"
className="w-full sm:w-auto order-2 sm:order-1" className="order-2 w-full sm:order-1 sm:w-auto"
> >
Cancel Cancel
</Button> </Button>
@@ -484,9 +562,9 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
type="submit" type="submit"
variant="default" variant="default"
size="default" size="default"
className="w-full sm:w-auto order-1 sm:order-2" className="order-1 w-full sm:order-2 sm:w-auto"
> >
{isEditing ? 'Update Server' : 'Add Server'} {isEditing ? "Update Server" : "Add Server"}
</Button> </Button>
</div> </div>
</form> </form>
@@ -496,10 +574,9 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
isOpen={showPublicKeyModal} isOpen={showPublicKeyModal}
onClose={() => setShowPublicKeyModal(false)} onClose={() => setShowPublicKeyModal(false)}
publicKey={generatedPublicKey} publicKey={generatedPublicKey}
serverName={formData.name || 'New Server'} serverName={formData.name || "New Server"}
serverIp={formData.ip} serverIp={formData.ip}
/> />
</> </>
); );
} }

View File

@@ -1,12 +1,18 @@
'use client'; "use client";
import { useState, useEffect } from 'react'; import { useState } from "react";
import { Button } from './ui/button'; import { Button } from "./ui/button";
import { Database, RefreshCw, CheckCircle, Lock, AlertCircle } from 'lucide-react'; import {
import { useRegisterModal } from './modal/ModalStackProvider'; Database,
import { api } from '~/trpc/react'; RefreshCw,
import { PBSCredentialsModal } from './PBSCredentialsModal'; CheckCircle,
import type { Storage } from '~/server/services/storageService'; Lock,
AlertCircle,
} from "lucide-react";
import { useRegisterModal } from "./modal/ModalStackProvider";
import { api } from "~/trpc/react";
import { PBSCredentialsModal } from "./PBSCredentialsModal";
import type { Storage } from "~/server/services/storageService";
interface ServerStoragesModalProps { interface ServerStoragesModalProps {
isOpen: boolean; isOpen: boolean;
@@ -19,30 +25,38 @@ export function ServerStoragesModal({
isOpen, isOpen,
onClose, onClose,
serverId, serverId,
serverName serverName,
}: ServerStoragesModalProps) { }: ServerStoragesModalProps) {
const [forceRefresh, setForceRefresh] = useState(false); const [forceRefresh, setForceRefresh] = useState(false);
const [selectedPBSStorage, setSelectedPBSStorage] = useState<Storage | null>(null); const [selectedPBSStorage, setSelectedPBSStorage] = useState<Storage | null>(
null,
);
const { data, isLoading, refetch } = api.installedScripts.getBackupStorages.useQuery( const { data, isLoading, refetch } =
api.installedScripts.getBackupStorages.useQuery(
{ serverId, forceRefresh }, { serverId, forceRefresh },
{ enabled: isOpen } { enabled: isOpen },
); );
// Fetch all PBS credentials for this server to show status indicators // Fetch all PBS credentials for this server to show status indicators
const { data: allCredentials } = api.pbsCredentials.getAllCredentialsForServer.useQuery( const { data: allCredentials } =
api.pbsCredentials.getAllCredentialsForServer.useQuery(
{ serverId }, { serverId },
{ enabled: isOpen } { enabled: isOpen },
); );
const credentialsMap = new Map<string, boolean>(); const credentialsMap = new Map<string, boolean>();
if (allCredentials?.success) { if (allCredentials?.success) {
allCredentials.credentials.forEach(c => { allCredentials.credentials.forEach((c) => {
credentialsMap.set(c.storage_name, true); credentialsMap.set(c.storage_name, true);
}); });
} }
useRegisterModal(isOpen, { id: 'server-storages-modal', allowEscape: true, onClose }); useRegisterModal(isOpen, {
id: "server-storages-modal",
allowEscape: true,
onClose,
});
const handleRefresh = () => { const handleRefresh = () => {
setForceRefresh(true); setForceRefresh(true);
@@ -53,16 +67,16 @@ export function ServerStoragesModal({
if (!isOpen) return null; if (!isOpen) return null;
const storages = data?.success ? data.storages : []; const storages = data?.success ? data.storages : [];
const backupStorages = storages.filter(s => s.supportsBackup); const backupStorages = storages.filter((s) => s.supportsBackup);
return ( return (
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<div className="bg-card rounded-lg shadow-xl max-w-3xl w-full max-h-[90vh] flex flex-col border border-border"> <div className="bg-card border-border flex max-h-[90vh] w-full max-w-3xl flex-col rounded-lg border shadow-xl">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border"> <div className="border-border flex items-center justify-between border-b p-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Database className="h-6 w-6 text-primary" /> <Database className="text-primary h-6 w-6" />
<h2 className="text-2xl font-bold text-card-foreground"> <h2 className="text-card-foreground text-2xl font-bold">
Storages for {serverName} Storages for {serverName}
</h2> </h2>
</div> </div>
@@ -73,7 +87,9 @@ export function ServerStoragesModal({
size="sm" size="sm"
disabled={isLoading} disabled={isLoading}
> >
<RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} /> <RefreshCw
className={`mr-2 h-4 w-4 ${isLoading ? "animate-spin" : ""}`}
/>
Refresh Refresh
</Button> </Button>
<Button <Button
@@ -82,8 +98,18 @@ export function ServerStoragesModal({
size="icon" size="icon"
className="text-muted-foreground hover:text-foreground" className="text-muted-foreground hover:text-foreground"
> >
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg> </svg>
</Button> </Button>
</div> </div>
@@ -92,35 +118,36 @@ export function ServerStoragesModal({
{/* Content */} {/* Content */}
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto p-6">
{isLoading ? ( {isLoading ? (
<div className="text-center py-8"> <div className="py-8 text-center">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-4"></div> <div className="border-primary mb-4 inline-block h-8 w-8 animate-spin rounded-full border-b-2"></div>
<p className="text-muted-foreground">Loading storages...</p> <p className="text-muted-foreground">Loading storages...</p>
</div> </div>
) : !data?.success ? ( ) : !data?.success ? (
<div className="text-center py-8"> <div className="py-8 text-center">
<Database className="h-12 w-12 text-muted-foreground mx-auto mb-4" /> <Database className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
<p className="text-foreground mb-2">Failed to load storages</p> <p className="text-foreground mb-2">Failed to load storages</p>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-muted-foreground mb-4 text-sm">
{data?.error ?? 'Unknown error occurred'} {data?.error ?? "Unknown error occurred"}
</p> </p>
<Button onClick={handleRefresh} variant="outline" size="sm"> <Button onClick={handleRefresh} variant="outline" size="sm">
<RefreshCw className="h-4 w-4 mr-2" /> <RefreshCw className="mr-2 h-4 w-4" />
Try Again Try Again
</Button> </Button>
</div> </div>
) : storages.length === 0 ? ( ) : storages.length === 0 ? (
<div className="text-center py-8"> <div className="py-8 text-center">
<Database className="h-12 w-12 text-muted-foreground mx-auto mb-4" /> <Database className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
<p className="text-foreground mb-2">No storages found</p> <p className="text-foreground mb-2">No storages found</p>
<p className="text-sm text-muted-foreground"> <p className="text-muted-foreground text-sm">
Make sure your server has storages configured. Make sure your server has storages configured.
</p> </p>
</div> </div>
) : ( ) : (
<> <>
{data.cached && ( {data.cached && (
<div className="mb-4 p-3 bg-muted/50 rounded-lg text-sm text-muted-foreground"> <div className="bg-muted/50 text-muted-foreground mb-4 rounded-lg p-3 text-sm">
Showing cached data. Click Refresh to fetch latest from server. Showing cached data. Click Refresh to fetch latest from
server.
</div> </div>
)} )}
@@ -131,57 +158,72 @@ export function ServerStoragesModal({
return ( return (
<div <div
key={storage.name} key={storage.name}
className={`p-4 border rounded-lg ${ className={`rounded-lg border p-4 ${
isBackupCapable isBackupCapable
? 'border-success/50 bg-success/5' ? "border-success/50 bg-success/5"
: 'border-border bg-card' : "border-border bg-card"
}`} }`}
> >
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-2 mb-2 flex-wrap"> <div className="mb-2 flex flex-wrap items-center gap-2">
<h3 className="font-medium text-foreground">{storage.name}</h3> <h3 className="text-foreground font-medium">
{storage.name}
</h3>
{isBackupCapable && ( {isBackupCapable && (
<span className="px-2 py-0.5 text-xs font-medium rounded bg-success/20 text-success border border-success/30 flex items-center gap-1"> <span className="bg-success/20 text-success border-success/30 flex items-center gap-1 rounded border px-2 py-0.5 text-xs font-medium">
<CheckCircle className="h-3 w-3" /> <CheckCircle className="h-3 w-3" />
Backup Backup
</span> </span>
)} )}
<span className="px-2 py-0.5 text-xs font-medium rounded bg-muted text-muted-foreground"> <span className="bg-muted text-muted-foreground rounded px-2 py-0.5 text-xs font-medium">
{storage.type} {storage.type}
</span> </span>
{storage.type === 'pbs' && ( {storage.type === "pbs" &&
credentialsMap.has(storage.name) ? ( (credentialsMap.has(storage.name) ? (
<span className="px-2 py-0.5 text-xs font-medium rounded bg-success/20 text-success border border-success/30 flex items-center gap-1"> <span className="bg-success/20 text-success border-success/30 flex items-center gap-1 rounded border px-2 py-0.5 text-xs font-medium">
<CheckCircle className="h-3 w-3" /> <CheckCircle className="h-3 w-3" />
Credentials Configured Credentials Configured
</span> </span>
) : ( ) : (
<span className="px-2 py-0.5 text-xs font-medium rounded bg-warning/20 text-warning border border-warning/30 flex items-center gap-1"> <span className="bg-warning/20 text-warning border-warning/30 flex items-center gap-1 rounded border px-2 py-0.5 text-xs font-medium">
<AlertCircle className="h-3 w-3" /> <AlertCircle className="h-3 w-3" />
Credentials Needed Credentials Needed
</span> </span>
) ))}
)}
</div> </div>
<div className="text-sm text-muted-foreground space-y-1"> <div className="text-muted-foreground space-y-1 text-sm">
<div> <div>
<span className="font-medium">Content:</span> {storage.content.join(', ')} <span className="font-medium">Content:</span>{" "}
{storage.content.join(", ")}
</div> </div>
{storage.nodes && storage.nodes.length > 0 && ( {storage.nodes && storage.nodes.length > 0 && (
<div> <div>
<span className="font-medium">Nodes:</span> {storage.nodes.join(', ')} <span className="font-medium">Nodes:</span>{" "}
{storage.nodes.join(", ")}
</div> </div>
)} )}
{Object.entries(storage) {Object.entries(storage)
.filter(([key]) => !['name', 'type', 'content', 'supportsBackup', 'nodes'].includes(key)) .filter(
([key]) =>
![
"name",
"type",
"content",
"supportsBackup",
"nodes",
].includes(key),
)
.map(([key, value]) => ( .map(([key, value]) => (
<div key={key}> <div key={key}>
<span className="font-medium capitalize">{key.replace(/_/g, ' ')}:</span> {String(value)} <span className="font-medium capitalize">
{key.replace(/_/g, " ")}:
</span>{" "}
{String(value)}
</div> </div>
))} ))}
</div> </div>
{storage.type === 'pbs' && ( {storage.type === "pbs" && (
<div className="mt-3 pt-3 border-t border-border"> <div className="border-border mt-3 border-t pt-3">
<Button <Button
onClick={() => setSelectedPBSStorage(storage)} onClick={() => setSelectedPBSStorage(storage)}
variant="outline" variant="outline"
@@ -189,7 +231,10 @@ export function ServerStoragesModal({
className="flex items-center gap-2" className="flex items-center gap-2"
> >
<Lock className="h-4 w-4" /> <Lock className="h-4 w-4" />
{credentialsMap.has(storage.name) ? 'Edit' : 'Configure'} Credentials {credentialsMap.has(storage.name)
? "Edit"
: "Configure"}{" "}
Credentials
</Button> </Button>
</div> </div>
)} )}
@@ -200,9 +245,11 @@ export function ServerStoragesModal({
</div> </div>
{backupStorages.length > 0 && ( {backupStorages.length > 0 && (
<div className="mt-6 p-4 bg-success/10 border border-success/20 rounded-lg"> <div className="bg-success/10 border-success/20 mt-6 rounded-lg border p-4">
<p className="text-sm text-success font-medium"> <p className="text-success text-sm font-medium">
{backupStorages.length} storage{backupStorages.length !== 1 ? 's' : ''} available for backups {backupStorages.length} storage
{backupStorages.length !== 1 ? "s" : ""} available for
backups
</p> </p>
</div> </div>
)} )}
@@ -224,4 +271,3 @@ export function ServerStoragesModal({
</div> </div>
); );
} }

View File

@@ -1,10 +1,10 @@
'use client'; "use client";
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from "react";
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { tomorrow } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { tomorrow } from "react-syntax-highlighter/dist/esm/styles/prism";
import { Button } from './ui/button'; import { Button } from "./ui/button";
import type { Script } from '../../types/script'; import type { Script } from "../../types/script";
interface TextViewerProps { interface TextViewerProps {
scriptName: string; scriptName: string;
@@ -20,19 +20,30 @@ interface ScriptContent {
alpineInstallScript?: string; alpineInstallScript?: string;
} }
export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerProps) { export function TextViewer({
scriptName,
isOpen,
onClose,
script,
}: TextViewerProps) {
const [scriptContent, setScriptContent] = useState<ScriptContent>({}); const [scriptContent, setScriptContent] = useState<ScriptContent>({});
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'main' | 'install'>('main'); const [activeTab, setActiveTab] = useState<"main" | "install">("main");
const [selectedVersion, setSelectedVersion] = useState<'default' | 'alpine'>('default'); const [selectedVersion, setSelectedVersion] = useState<"default" | "alpine">(
"default",
);
// Extract slug from script name (remove .sh extension) // Extract slug from script name (remove .sh extension)
const slug = scriptName.replace(/\.sh$/, '').replace(/^alpine-/, ''); const slug = scriptName.replace(/\.sh$/, "").replace(/^alpine-/, "");
// Get default and alpine install methods // Get default and alpine install methods
const defaultMethod = script?.install_methods?.find(method => method.type === 'default'); const defaultMethod = script?.install_methods?.find(
const alpineMethod = script?.install_methods?.find(method => method.type === 'alpine'); (method) => method.type === "default",
);
const alpineMethod = script?.install_methods?.find(
(method) => method.type === "alpine",
);
// Check if alpine variant exists // Check if alpine variant exists
const hasAlpineVariant = !!alpineMethod; const hasAlpineVariant = !!alpineMethod;
@@ -42,11 +53,11 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
const alpineScriptPath = alpineMethod?.script; const alpineScriptPath = alpineMethod?.script;
// Determine if install scripts exist (only for ct/ scripts typically) // Determine if install scripts exist (only for ct/ scripts typically)
const hasInstallScript = defaultScriptPath?.startsWith('ct/') || alpineScriptPath?.startsWith('ct/'); const hasInstallScript =
defaultScriptPath?.startsWith("ct/") ?? alpineScriptPath?.startsWith("ct/");
// Get script names for display // Get script names for display
const defaultScriptName = scriptName.replace(/^alpine-/, ''); const defaultScriptName = scriptName.replace(/^alpine-/, "");
const alpineScriptName = scriptName.startsWith('alpine-') ? scriptName : `alpine-${scriptName}`;
const loadScriptContent = useCallback(async () => { const loadScriptContent = useCallback(async () => {
setIsLoading(true); setIsLoading(true);
@@ -55,61 +66,83 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
try { try {
// Build fetch requests based on actual script paths from install_methods // Build fetch requests based on actual script paths from install_methods
const requests: Promise<Response>[] = []; const requests: Promise<Response>[] = [];
const requestTypes: Array<'default-main' | 'default-install' | 'alpine-main' | 'alpine-install'> = []; const requestTypes: Array<
"default-main" | "default-install" | "alpine-main" | "alpine-install"
> = [];
// Default main script (ct/, vm/, tools/, etc.) // Default main script (ct/, vm/, tools/, etc.)
if (defaultScriptPath) { if (defaultScriptPath) {
requests.push( requests.push(
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: defaultScriptPath } }))}`) fetch(
`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: defaultScriptPath } }))}`,
),
); );
requestTypes.push('default-main'); requestTypes.push("default-main");
} }
// Default install script (only for ct/ scripts) // Default install script (only for ct/ scripts)
if (hasInstallScript && defaultScriptPath?.startsWith('ct/')) { if (hasInstallScript && defaultScriptPath?.startsWith("ct/")) {
requests.push( requests.push(
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/${slug}-install.sh` } }))}`) fetch(
`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/${slug}-install.sh` } }))}`,
),
); );
requestTypes.push('default-install'); requestTypes.push("default-install");
} }
// Alpine main script // Alpine main script
if (hasAlpineVariant && alpineScriptPath) { if (hasAlpineVariant && alpineScriptPath) {
requests.push( requests.push(
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: alpineScriptPath } }))}`) fetch(
`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: alpineScriptPath } }))}`,
),
); );
requestTypes.push('alpine-main'); requestTypes.push("alpine-main");
} }
// Alpine install script (only for ct/ scripts) // Alpine install script (only for ct/ scripts)
if (hasAlpineVariant && hasInstallScript && alpineScriptPath?.startsWith('ct/')) { if (
hasAlpineVariant &&
hasInstallScript &&
alpineScriptPath?.startsWith("ct/")
) {
requests.push( requests.push(
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/alpine-${slug}-install.sh` } }))}`) fetch(
`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/alpine-${slug}-install.sh` } }))}`,
),
); );
requestTypes.push('alpine-install'); requestTypes.push("alpine-install");
} }
const responses = await Promise.allSettled(requests); const responses = await Promise.allSettled(requests);
const content: ScriptContent = {}; const content: ScriptContent = {};
// Process responses based on their types // Process responses based on their types
await Promise.all(responses.map(async (response, index) => { await Promise.all(
if (response.status === 'fulfilled' && response.value.ok) { responses.map(async (response, index) => {
if (response.status === "fulfilled" && response.value.ok) {
try { try {
const data = await response.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } }; const data = (await response.value.json()) as {
result?: {
data?: { json?: { success?: boolean; content?: string } };
};
};
const type = requestTypes[index]; const type = requestTypes[index];
if (data.result?.data?.json?.success && data.result.data.json.content) { if (
data.result?.data?.json?.success &&
data.result.data.json.content
) {
switch (type) { switch (type) {
case 'default-main': case "default-main":
content.mainScript = data.result.data.json.content; content.mainScript = data.result.data.json.content;
break; break;
case 'default-install': case "default-install":
content.installScript = data.result.data.json.content; content.installScript = data.result.data.json.content;
break; break;
case 'alpine-main': case "alpine-main":
content.alpineMainScript = data.result.data.json.content; content.alpineMainScript = data.result.data.json.content;
break; break;
case 'alpine-install': case "alpine-install":
content.alpineInstallScript = data.result.data.json.content; content.alpineInstallScript = data.result.data.json.content;
break; break;
} }
@@ -118,15 +151,24 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
// Ignore errors // Ignore errors
} }
} }
})); }),
);
setScriptContent(content); setScriptContent(content);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load script content'); setError(
err instanceof Error ? err.message : "Failed to load script content",
);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [defaultScriptPath, alpineScriptPath, slug, hasAlpineVariant, hasInstallScript]); }, [
defaultScriptPath,
alpineScriptPath,
slug,
hasAlpineVariant,
hasInstallScript,
]);
useEffect(() => { useEffect(() => {
if (isOpen && scriptName) { if (isOpen && scriptName) {
@@ -144,48 +186,53 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
return ( return (
<div <div
className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center p-4 z-50" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
onClick={handleBackdropClick} onClick={handleBackdropClick}
> >
<div className="bg-card rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] flex flex-col border border-border mx-4 sm:mx-0"> <div className="bg-card border-border mx-4 flex max-h-[90vh] w-full max-w-6xl flex-col rounded-lg border shadow-xl sm:mx-0">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border"> <div className="border-border flex items-center justify-between border-b p-6">
<div className="flex items-center space-x-4 flex-1"> <div className="flex flex-1 items-center space-x-4">
<h2 className="text-2xl font-bold text-foreground"> <h2 className="text-foreground text-2xl font-bold">
Script Viewer: {defaultScriptName} Script Viewer: {defaultScriptName}
</h2> </h2>
{hasAlpineVariant && ( {hasAlpineVariant && (
<div className="flex space-x-2"> <div className="flex space-x-2">
<Button <Button
variant={selectedVersion === 'default' ? 'default' : 'outline'} variant={
onClick={() => setSelectedVersion('default')} selectedVersion === "default" ? "default" : "outline"
}
onClick={() => setSelectedVersion("default")}
className="px-3 py-1 text-sm" className="px-3 py-1 text-sm"
> >
Default Default
</Button> </Button>
<Button <Button
variant={selectedVersion === 'alpine' ? 'default' : 'outline'} variant={selectedVersion === "alpine" ? "default" : "outline"}
onClick={() => setSelectedVersion('alpine')} onClick={() => setSelectedVersion("alpine")}
className="px-3 py-1 text-sm" className="px-3 py-1 text-sm"
> >
Alpine Alpine
</Button> </Button>
</div> </div>
)} )}
{((selectedVersion === 'default' && (scriptContent.mainScript || scriptContent.installScript)) || {((selectedVersion === "default" &&
(selectedVersion === 'alpine' && (scriptContent.alpineMainScript || scriptContent.alpineInstallScript))) && ( (scriptContent.mainScript || scriptContent.installScript)) ||
(selectedVersion === "alpine" &&
(scriptContent.alpineMainScript ||
scriptContent.alpineInstallScript))) && (
<div className="flex space-x-2"> <div className="flex space-x-2">
<Button <Button
variant={activeTab === 'main' ? 'outline' : 'ghost'} variant={activeTab === "main" ? "outline" : "ghost"}
onClick={() => setActiveTab('main')} onClick={() => setActiveTab("main")}
className="px-3 py-1 text-sm" className="px-3 py-1 text-sm"
> >
Script Script
</Button> </Button>
{hasInstallScript && ( {hasInstallScript && (
<Button <Button
variant={activeTab === 'install' ? 'outline' : 'ghost'} variant={activeTab === "install" ? "outline" : "ghost"}
onClick={() => setActiveTab('install')} onClick={() => setActiveTab("install")}
className="px-3 py-1 text-sm" className="px-3 py-1 text-sm"
> >
Install Script Install Script
@@ -198,51 +245,64 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
onClick={onClose} onClick={onClose}
className="text-muted-foreground hover:text-foreground transition-colors" className="text-muted-foreground hover:text-foreground transition-colors"
> >
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> className="h-6 w-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg> </svg>
</button> </button>
</div> </div>
{/* Content */} {/* Content */}
<div className="flex-1 overflow-hidden flex flex-col"> <div className="flex flex-1 flex-col overflow-hidden">
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center h-full"> <div className="flex h-full items-center justify-center">
<div className="text-lg text-muted-foreground">Loading script content...</div> <div className="text-muted-foreground text-lg">
Loading script content...
</div>
</div> </div>
) : error ? ( ) : error ? (
<div className="flex items-center justify-center h-full"> <div className="flex h-full items-center justify-center">
<div className="text-lg text-destructive">Error: {error}</div> <div className="text-destructive text-lg">Error: {error}</div>
</div> </div>
) : ( ) : (
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
{activeTab === 'main' && ( {activeTab === "main" &&
selectedVersion === 'default' && scriptContent.mainScript ? ( (selectedVersion === "default" && scriptContent.mainScript ? (
<SyntaxHighlighter <SyntaxHighlighter
language="bash" language="bash"
style={tomorrow} style={tomorrow}
customStyle={{ customStyle={{
margin: 0, margin: 0,
padding: '1rem', padding: "1rem",
fontSize: '14px', fontSize: "14px",
lineHeight: '1.5', lineHeight: "1.5",
minHeight: '100%' minHeight: "100%",
}} }}
showLineNumbers={true} showLineNumbers={true}
wrapLines={true} wrapLines={true}
> >
{scriptContent.mainScript} {scriptContent.mainScript}
</SyntaxHighlighter> </SyntaxHighlighter>
) : selectedVersion === 'alpine' && scriptContent.alpineMainScript ? ( ) : selectedVersion === "alpine" &&
scriptContent.alpineMainScript ? (
<SyntaxHighlighter <SyntaxHighlighter
language="bash" language="bash"
style={tomorrow} style={tomorrow}
customStyle={{ customStyle={{
margin: 0, margin: 0,
padding: '1rem', padding: "1rem",
fontSize: '14px', fontSize: "14px",
lineHeight: '1.5', lineHeight: "1.5",
minHeight: '100%' minHeight: "100%",
}} }}
showLineNumbers={true} showLineNumbers={true}
wrapLines={true} wrapLines={true}
@@ -250,40 +310,43 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
{scriptContent.alpineMainScript} {scriptContent.alpineMainScript}
</SyntaxHighlighter> </SyntaxHighlighter>
) : ( ) : (
<div className="flex items-center justify-center h-full"> <div className="flex h-full items-center justify-center">
<div className="text-lg text-muted-foreground"> <div className="text-muted-foreground text-lg">
{selectedVersion === 'default' ? 'Default script not found' : 'Alpine script not found'} {selectedVersion === "default"
? "Default script not found"
: "Alpine script not found"}
</div> </div>
</div> </div>
) ))}
)} {activeTab === "install" &&
{activeTab === 'install' && ( (selectedVersion === "default" &&
selectedVersion === 'default' && scriptContent.installScript ? ( scriptContent.installScript ? (
<SyntaxHighlighter <SyntaxHighlighter
language="bash" language="bash"
style={tomorrow} style={tomorrow}
customStyle={{ customStyle={{
margin: 0, margin: 0,
padding: '1rem', padding: "1rem",
fontSize: '14px', fontSize: "14px",
lineHeight: '1.5', lineHeight: "1.5",
minHeight: '100%' minHeight: "100%",
}} }}
showLineNumbers={true} showLineNumbers={true}
wrapLines={true} wrapLines={true}
> >
{scriptContent.installScript} {scriptContent.installScript}
</SyntaxHighlighter> </SyntaxHighlighter>
) : selectedVersion === 'alpine' && scriptContent.alpineInstallScript ? ( ) : selectedVersion === "alpine" &&
scriptContent.alpineInstallScript ? (
<SyntaxHighlighter <SyntaxHighlighter
language="bash" language="bash"
style={tomorrow} style={tomorrow}
customStyle={{ customStyle={{
margin: 0, margin: 0,
padding: '1rem', padding: "1rem",
fontSize: '14px', fontSize: "14px",
lineHeight: '1.5', lineHeight: "1.5",
minHeight: '100%' minHeight: "100%",
}} }}
showLineNumbers={true} showLineNumbers={true}
wrapLines={true} wrapLines={true}
@@ -291,13 +354,14 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
{scriptContent.alpineInstallScript} {scriptContent.alpineInstallScript}
</SyntaxHighlighter> </SyntaxHighlighter>
) : ( ) : (
<div className="flex items-center justify-center h-full"> <div className="flex h-full items-center justify-center">
<div className="text-lg text-muted-foreground"> <div className="text-muted-foreground text-lg">
{selectedVersion === 'default' ? 'Default install script not found' : 'Alpine install script not found'} {selectedVersion === "default"
? "Default install script not found"
: "Alpine install script not found"}
</div> </div>
</div> </div>
) ))}
)}
</div> </div>
)} )}
</div> </div>

View File

@@ -1,12 +1,11 @@
'use client'; "use client";
import { api } from '~/trpc/react'; import { Button } from "./ui/button";
import { Button } from './ui/button'; import { Badge } from "./ui/badge";
import { Badge } from './ui/badge'; import { X, ExternalLink, Calendar, Tag, AlertTriangle } from "lucide-react";
import { X, ExternalLink, Calendar, Tag, Loader2, AlertTriangle } from 'lucide-react'; import { useRegisterModal } from "./modal/ModalStackProvider";
import { useRegisterModal } from './modal/ModalStackProvider'; import ReactMarkdown from "react-markdown";
import ReactMarkdown from 'react-markdown'; import remarkGfm from "remark-gfm";
import remarkGfm from 'remark-gfm';
interface UpdateConfirmationModalProps { interface UpdateConfirmationModalProps {
isOpen: boolean; isOpen: boolean;
@@ -29,22 +28,28 @@ export function UpdateConfirmationModal({
onConfirm, onConfirm,
releaseInfo, releaseInfo,
currentVersion, currentVersion,
latestVersion latestVersion,
}: UpdateConfirmationModalProps) { }: UpdateConfirmationModalProps) {
useRegisterModal(isOpen, { id: 'update-confirmation-modal', allowEscape: true, onClose }); useRegisterModal(isOpen, {
id: "update-confirmation-modal",
allowEscape: true,
onClose,
});
if (!isOpen || !releaseInfo) return null; if (!isOpen || !releaseInfo) return null;
return ( return (
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<div className="bg-card rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] flex flex-col border border-border"> <div className="bg-card border-border flex max-h-[90vh] w-full max-w-4xl flex-col rounded-lg border shadow-xl">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border"> <div className="border-border flex items-center justify-between border-b p-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<AlertTriangle className="h-6 w-6 text-warning" /> <AlertTriangle className="text-warning h-6 w-6" />
<div> <div>
<h2 className="text-2xl font-bold text-card-foreground">Confirm Update</h2> <h2 className="text-card-foreground text-2xl font-bold">
<p className="text-sm text-muted-foreground mt-1"> Confirm Update
</h2>
<p className="text-muted-foreground mt-1 text-sm">
Review the changelog before proceeding with the update Review the changelog before proceeding with the update
</p> </p>
</div> </div>
@@ -60,13 +65,13 @@ export function UpdateConfirmationModal({
</div> </div>
{/* Content */} {/* Content */}
<div className="flex-1 overflow-hidden flex flex-col"> <div className="flex flex-1 flex-col overflow-hidden">
<div className="flex-1 overflow-y-auto p-6 space-y-4"> <div className="flex-1 space-y-4 overflow-y-auto p-6">
{/* Version Info */} {/* Version Info */}
<div className="bg-muted/50 rounded-lg p-4 border border-border"> <div className="bg-muted/50 border-border rounded-lg border p-4">
<div className="flex items-center justify-between mb-3"> <div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h3 className="text-lg font-semibold text-card-foreground"> <h3 className="text-card-foreground text-lg font-semibold">
{releaseInfo.name || releaseInfo.tagName} {releaseInfo.name || releaseInfo.tagName}
</h3> </h3>
<Badge variant="default" className="text-xs"> <Badge variant="default" className="text-xs">
@@ -89,7 +94,7 @@ export function UpdateConfirmationModal({
</a> </a>
</Button> </Button>
</div> </div>
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-3"> <div className="text-muted-foreground mb-3 flex items-center gap-4 text-sm">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Tag className="h-4 w-4" /> <Tag className="h-4 w-4" />
<span>{releaseInfo.tagName}</span> <span>{releaseInfo.tagName}</span>
@@ -97,40 +102,92 @@ export function UpdateConfirmationModal({
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Calendar className="h-4 w-4" /> <Calendar className="h-4 w-4" />
<span> <span>
{new Date(releaseInfo.publishedAt).toLocaleDateString('en-US', { {new Date(releaseInfo.publishedAt).toLocaleDateString(
year: 'numeric', "en-US",
month: 'long', {
day: 'numeric' year: "numeric",
})} month: "long",
day: "numeric",
},
)}
</span> </span>
</div> </div>
</div> </div>
<div className="text-sm text-muted-foreground"> <div className="text-muted-foreground text-sm">
<span>Updating from </span> <span>Updating from </span>
<span className="font-medium text-card-foreground">v{currentVersion}</span> <span className="text-card-foreground font-medium">
v{currentVersion}
</span>
<span> to </span> <span> to </span>
<span className="font-medium text-card-foreground">v{latestVersion}</span> <span className="text-card-foreground font-medium">
v{latestVersion}
</span>
</div> </div>
</div> </div>
{/* Changelog */} {/* Changelog */}
{releaseInfo.body ? ( {releaseInfo.body ? (
<div className="border rounded-lg p-6 border-border bg-card"> <div className="border-border bg-card rounded-lg border p-6">
<h4 className="text-md font-semibold text-card-foreground mb-4">Changelog</h4> <h4 className="text-md text-card-foreground mb-4 font-semibold">
<div className="prose prose-sm max-w-none dark:prose-invert"> Changelog
</h4>
<div className="prose prose-sm dark:prose-invert max-w-none">
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
components={{ components={{
h1: ({children}) => <h1 className="text-2xl font-bold text-card-foreground mb-4 mt-6">{children}</h1>, h1: ({ children }) => (
h2: ({children}) => <h2 className="text-xl font-semibold text-card-foreground mb-3 mt-5">{children}</h2>, <h1 className="text-card-foreground mt-6 mb-4 text-2xl font-bold">
h3: ({children}) => <h3 className="text-lg font-medium text-card-foreground mb-2 mt-4">{children}</h3>, {children}
p: ({children}) => <p className="text-card-foreground mb-3 leading-relaxed">{children}</p>, </h1>
ul: ({children}) => <ul className="list-disc list-inside text-card-foreground mb-3 space-y-1">{children}</ul>, ),
ol: ({children}) => <ol className="list-decimal list-inside text-card-foreground mb-3 space-y-1">{children}</ol>, h2: ({ children }) => (
li: ({children}) => <li className="text-card-foreground">{children}</li>, <h2 className="text-card-foreground mt-5 mb-3 text-xl font-semibold">
a: ({href, children}) => <a href={href} className="text-info hover:text-info/80 underline" target="_blank" rel="noopener noreferrer">{children}</a>, {children}
strong: ({children}) => <strong className="font-semibold text-card-foreground">{children}</strong>, </h2>
em: ({children}) => <em className="italic text-card-foreground">{children}</em>, ),
h3: ({ children }) => (
<h3 className="text-card-foreground mt-4 mb-2 text-lg font-medium">
{children}
</h3>
),
p: ({ children }) => (
<p className="text-card-foreground mb-3 leading-relaxed">
{children}
</p>
),
ul: ({ children }) => (
<ul className="text-card-foreground mb-3 list-inside list-disc space-y-1">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="text-card-foreground mb-3 list-inside list-decimal space-y-1">
{children}
</ol>
),
li: ({ children }) => (
<li className="text-card-foreground">{children}</li>
),
a: ({ href, children }) => (
<a
href={href}
className="text-info hover:text-info/80 underline"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
strong: ({ children }) => (
<strong className="text-card-foreground font-semibold">
{children}
</strong>
),
em: ({ children }) => (
<em className="text-card-foreground italic">
{children}
</em>
),
}} }}
> >
{releaseInfo.body} {releaseInfo.body}
@@ -138,20 +195,23 @@ export function UpdateConfirmationModal({
</div> </div>
</div> </div>
) : ( ) : (
<div className="border rounded-lg p-6 border-border bg-card"> <div className="border-border bg-card rounded-lg border p-6">
<p className="text-muted-foreground">No changelog available for this release.</p> <p className="text-muted-foreground">
No changelog available for this release.
</p>
</div> </div>
)} )}
{/* Warning */} {/* Warning */}
<div className="bg-warning/10 border border-warning/30 rounded-lg p-4"> <div className="bg-warning/10 border-warning/30 rounded-lg border p-4">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-warning mt-0.5 flex-shrink-0" /> <AlertTriangle className="text-warning mt-0.5 h-5 w-5 flex-shrink-0" />
<div className="text-sm text-card-foreground"> <div className="text-card-foreground text-sm">
<p className="font-medium mb-1">Important:</p> <p className="mb-1 font-medium">Important:</p>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Please review the changelog above for any breaking changes or important updates before proceeding. Please review the changelog above for any breaking changes
The server will restart automatically after the update completes. or important updates before proceeding. The server will
restart automatically after the update completes.
</p> </p>
</div> </div>
</div> </div>
@@ -160,7 +220,7 @@ export function UpdateConfirmationModal({
</div> </div>
{/* Footer */} {/* Footer */}
<div className="flex items-center justify-between p-6 border-t border-border bg-muted/30"> <div className="border-border bg-muted/30 flex items-center justify-between border-t p-6">
<Button onClick={onClose} variant="ghost"> <Button onClick={onClose} variant="ghost">
Cancel Cancel
</Button> </Button>
@@ -172,5 +232,3 @@ export function UpdateConfirmationModal({
</div> </div>
); );
} }

View File

@@ -1,51 +1,70 @@
"use client";
'use client'; import { useState, useRef, useEffect } from "react";
import { ScriptsGrid } from "./_components/ScriptsGrid";
import { useState, useRef, useEffect } from 'react'; import { DownloadedScriptsTab } from "./_components/DownloadedScriptsTab";
import { ScriptsGrid } from './_components/ScriptsGrid'; import { InstalledScriptsTab } from "./_components/InstalledScriptsTab";
import { DownloadedScriptsTab } from './_components/DownloadedScriptsTab'; import { BackupsTab } from "./_components/BackupsTab";
import { InstalledScriptsTab } from './_components/InstalledScriptsTab'; import { ResyncButton } from "./_components/ResyncButton";
import { BackupsTab } from './_components/BackupsTab'; import { Terminal } from "./_components/Terminal";
import { ResyncButton } from './_components/ResyncButton'; import { ServerSettingsButton } from "./_components/ServerSettingsButton";
import { Terminal } from './_components/Terminal'; import { SettingsButton } from "./_components/SettingsButton";
import { ServerSettingsButton } from './_components/ServerSettingsButton'; import { HelpButton } from "./_components/HelpButton";
import { SettingsButton } from './_components/SettingsButton'; import { VersionDisplay } from "./_components/VersionDisplay";
import { HelpButton } from './_components/HelpButton'; import { ThemeToggle } from "./_components/ThemeToggle";
import { VersionDisplay } from './_components/VersionDisplay'; import { Button } from "./_components/ui/button";
import { ThemeToggle } from './_components/ThemeToggle'; import { ContextualHelpIcon } from "./_components/ContextualHelpIcon";
import { Button } from './_components/ui/button'; import {
import { ContextualHelpIcon } from './_components/ContextualHelpIcon'; ReleaseNotesModal,
import { ReleaseNotesModal, getLastSeenVersion } from './_components/ReleaseNotesModal'; getLastSeenVersion,
import { Footer } from './_components/Footer'; } from "./_components/ReleaseNotesModal";
import { Package, HardDrive, FolderOpen, LogOut, Archive } from 'lucide-react'; import { Footer } from "./_components/Footer";
import { api } from '~/trpc/react'; import { Package, HardDrive, FolderOpen, LogOut, Archive } from "lucide-react";
import { useAuth } from './_components/AuthProvider'; import { api } from "~/trpc/react";
import { useAuth } from "./_components/AuthProvider";
import type { Server } from "~/types/server";
export default function Home() { export default function Home() {
const { isAuthenticated, logout } = useAuth(); const { isAuthenticated, logout } = useAuth();
const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null); const [runningScript, setRunningScript] = useState<{
const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed' | 'backups'>(() => { path: string;
if (typeof window !== 'undefined') { name: string;
const savedTab = localStorage.getItem('activeTab') as 'scripts' | 'downloaded' | 'installed' | 'backups'; mode?: "local" | "ssh";
return savedTab || 'scripts'; server?: Server;
} | null>(null);
const [activeTab, setActiveTab] = useState<
"scripts" | "downloaded" | "installed" | "backups"
>(() => {
if (typeof window !== "undefined") {
const savedTab = localStorage.getItem("activeTab") as
| "scripts"
| "downloaded"
| "installed"
| "backups";
return savedTab || "scripts";
} }
return 'scripts'; return "scripts";
}); });
const [releaseNotesOpen, setReleaseNotesOpen] = useState(false); const [releaseNotesOpen, setReleaseNotesOpen] = useState(false);
const [highlightVersion, setHighlightVersion] = useState<string | undefined>(undefined); const [highlightVersion, setHighlightVersion] = useState<string | undefined>(
undefined,
);
const terminalRef = useRef<HTMLDivElement>(null); const terminalRef = useRef<HTMLDivElement>(null);
// Fetch data for script counts // Fetch data for script counts
const { data: scriptCardsData } = api.scripts.getScriptCardsWithCategories.useQuery(); const { data: scriptCardsData } =
const { data: localScriptsData } = api.scripts.getAllDownloadedScripts.useQuery(); api.scripts.getScriptCardsWithCategories.useQuery();
const { data: installedScriptsData } = api.installedScripts.getAllInstalledScripts.useQuery(); const { data: localScriptsData } =
api.scripts.getAllDownloadedScripts.useQuery();
const { data: installedScriptsData } =
api.installedScripts.getAllInstalledScripts.useQuery();
const { data: backupsData } = api.backups.getAllBackupsGrouped.useQuery(); const { data: backupsData } = api.backups.getAllBackupsGrouped.useQuery();
const { data: versionData } = api.version.getCurrentVersion.useQuery(); const { data: versionData } = api.version.getCurrentVersion.useQuery();
// Save active tab to localStorage whenever it changes // Save active tab to localStorage whenever it changes
useEffect(() => { useEffect(() => {
if (typeof window !== 'undefined') { if (typeof window !== "undefined") {
localStorage.setItem('activeTab', activeTab); localStorage.setItem("activeTab", activeTab);
} }
}, [activeTab]); }, [activeTab]);
@@ -56,7 +75,10 @@ export default function Home() {
const lastSeenVersion = getLastSeenVersion(); const lastSeenVersion = getLastSeenVersion();
// If we have a current version and either no last seen version or versions don't match // If we have a current version and either no last seen version or versions don't match
if (currentVersion && (!lastSeenVersion || currentVersion !== lastSeenVersion)) { if (
currentVersion &&
(!lastSeenVersion || currentVersion !== lastSeenVersion)
) {
setHighlightVersion(currentVersion); setHighlightVersion(currentVersion);
setReleaseNotesOpen(true); setReleaseNotesOpen(true);
} }
@@ -81,7 +103,7 @@ export default function Home() {
// Deduplicate scripts using Map by slug (same logic as ScriptsGrid.tsx) // Deduplicate scripts using Map by slug (same logic as ScriptsGrid.tsx)
const scriptMap = new Map<string, any>(); const scriptMap = new Map<string, any>();
scriptCardsData.cards?.forEach(script => { scriptCardsData.cards?.forEach((script) => {
if (script?.name && script?.slug) { if (script?.name && script?.slug) {
// Use slug as unique identifier, only keep first occurrence // Use slug as unique identifier, only keep first occurrence
if (!scriptMap.has(script.slug)) { if (!scriptMap.has(script.slug)) {
@@ -96,16 +118,17 @@ export default function Home() {
if (!scriptCardsData?.success || !localScriptsData?.scripts) return 0; if (!scriptCardsData?.success || !localScriptsData?.scripts) return 0;
// Helper to normalize identifiers for robust matching // Helper to normalize identifiers for robust matching
const normalizeId = (s?: string): string => (s ?? '') const normalizeId = (s?: string): string =>
(s ?? "")
.toLowerCase() .toLowerCase()
.replace(/\.(sh|bash|py|js|ts)$/g, '') .replace(/\.(sh|bash|py|js|ts)$/g, "")
.replace(/[^a-z0-9]+/g, '-') .replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, ''); .replace(/^-+|-+$/g, "");
// First deduplicate GitHub scripts using Map by slug // First deduplicate GitHub scripts using Map by slug
const scriptMap = new Map<string, any>(); const scriptMap = new Map<string, any>();
scriptCardsData.cards?.forEach(script => { scriptCardsData.cards?.forEach((script) => {
if (script?.name && script?.slug) { if (script?.name && script?.slug) {
if (!scriptMap.has(script.slug)) { if (!scriptMap.has(script.slug)) {
scriptMap.set(script.slug, script); scriptMap.set(script.slug, script);
@@ -118,11 +141,11 @@ export default function Home() {
// Count scripts that are both in deduplicated GitHub data and have local versions // Count scripts that are both in deduplicated GitHub data and have local versions
// Use the same matching logic as DownloadedScriptsTab and ScriptsGrid // Use the same matching logic as DownloadedScriptsTab and ScriptsGrid
return deduplicatedGithubScripts.filter(script => { return deduplicatedGithubScripts.filter((script) => {
if (!script?.name) return false; if (!script?.name) return false;
// Check if there's a corresponding local script // Check if there's a corresponding local script
return localScripts.some(local => { return localScripts.some((local) => {
if (!local?.name) return false; if (!local?.name) return false;
// Primary: Exact slug-to-slug matching (most reliable) // Primary: Exact slug-to-slug matching (most reliable)
@@ -138,11 +161,17 @@ export default function Home() {
// Secondary: Check install basenames (for edge cases where install script names differ from slugs) // Secondary: Check install basenames (for edge cases where install script names differ from slugs)
const normalizedLocal = normalizeId(local.name); const normalizedLocal = normalizeId(local.name);
const matchesInstallBasename = (script as any)?.install_basenames?.some((base: string) => normalizeId(base) === normalizedLocal) ?? false; const matchesInstallBasename =
(script as any)?.install_basenames?.some(
(base: string) => normalizeId(base) === normalizedLocal,
) ?? false;
if (matchesInstallBasename) return true; if (matchesInstallBasename) return true;
// Tertiary: Normalized filename to normalized slug matching // Tertiary: Normalized filename to normalized slug matching
if (script.slug && normalizeId(local.name) === normalizeId(script.slug)) { if (
script.slug &&
normalizeId(local.name) === normalizeId(script.slug)
) {
return true; return true;
} }
@@ -151,7 +180,7 @@ export default function Home() {
}).length; }).length;
})(), })(),
installed: installedScriptsData?.scripts?.length ?? 0, installed: installedScriptsData?.scripts?.length ?? 0,
backups: backupsData?.success ? backupsData.backups.length : 0 backups: backupsData?.success ? backupsData.backups.length : 0,
}; };
const scrollToTerminal = () => { const scrollToTerminal = () => {
@@ -162,12 +191,17 @@ export default function Home() {
window.scrollTo({ window.scrollTo({
top: elementTop - offset, top: elementTop - offset,
behavior: 'smooth' behavior: "smooth",
}); });
} }
}; };
const handleRunScript = (scriptPath: string, scriptName: string, mode?: 'local' | 'ssh', server?: any) => { const handleRunScript = (
scriptPath: string,
scriptName: string,
mode?: "local" | "ssh",
server?: Server,
) => {
setRunningScript({ path: scriptPath, name: scriptName, mode, server }); setRunningScript({ path: scriptPath, name: scriptName, mode, server });
// Scroll to terminal after a short delay to ensure it's rendered // Scroll to terminal after a short delay to ensure it's rendered
setTimeout(scrollToTerminal, 100); setTimeout(scrollToTerminal, 100);
@@ -178,16 +212,16 @@ export default function Home() {
}; };
return ( return (
<main className="min-h-screen bg-background"> <main className="bg-background min-h-screen">
<div className="container mx-auto px-2 sm:px-4 py-4 sm:py-8"> <div className="container mx-auto px-2 py-4 sm:px-4 sm:py-8">
{/* Header */} {/* Header */}
<div className="text-center mb-6 sm:mb-8"> <div className="mb-6 text-center sm:mb-8">
<div className="flex justify-between items-start mb-2"> <div className="mb-2 flex items-start justify-between">
<div className="flex-1"></div> <div className="flex-1"></div>
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold text-foreground flex items-center justify-center gap-2 sm:gap-3 flex-1"> <h1 className="text-foreground flex flex-1 items-center justify-center gap-2 text-2xl font-bold sm:gap-3 sm:text-3xl lg:text-4xl">
<span className="break-words">PVE Scripts Management</span> <span className="break-words">PVE Scripts Management</span>
</h1> </h1>
<div className="flex-1 flex justify-end items-center gap-2"> <div className="flex flex-1 items-center justify-end gap-2">
{isAuthenticated && ( {isAuthenticated && (
<Button <Button
variant="ghost" variant="ghost"
@@ -203,8 +237,9 @@ export default function Home() {
<ThemeToggle /> <ThemeToggle />
</div> </div>
</div> </div>
<p className="text-sm sm:text-base text-muted-foreground mb-4 px-2"> <p className="text-muted-foreground mb-4 px-2 text-sm sm:text-base">
Manage and execute Proxmox helper scripts locally with live output streaming Manage and execute Proxmox helper scripts locally with live output
streaming
</p> </p>
<div className="flex justify-center px-2"> <div className="flex justify-center px-2">
<VersionDisplay onOpenReleaseNotes={handleOpenReleaseNotes} /> <VersionDisplay onOpenReleaseNotes={handleOpenReleaseNotes} />
@@ -213,7 +248,7 @@ export default function Home() {
{/* Controls */} {/* Controls */}
<div className="mb-6 sm:mb-8"> <div className="mb-6 sm:mb-8">
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-4 p-4 sm:p-6 bg-card rounded-lg shadow-sm border border-border"> <div className="bg-card border-border flex flex-col gap-4 rounded-lg border p-4 shadow-sm sm:flex-row sm:flex-wrap sm:items-center sm:p-6">
<ServerSettingsButton /> <ServerSettingsButton />
<SettingsButton /> <SettingsButton />
<ResyncButton /> <ResyncButton />
@@ -223,72 +258,85 @@ export default function Home() {
{/* Tab Navigation */} {/* Tab Navigation */}
<div className="mb-6 sm:mb-8"> <div className="mb-6 sm:mb-8">
<div className="border-b border-border"> <div className="border-border border-b">
<nav className="-mb-px flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-1"> <nav className="-mb-px flex flex-col space-y-2 sm:flex-row sm:space-y-0 sm:space-x-1">
<Button <Button
variant="ghost" variant="ghost"
size="null" size="null"
onClick={() => setActiveTab('scripts')} onClick={() => setActiveTab("scripts")}
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${ className={`flex w-full items-center justify-center gap-2 px-3 py-2 text-sm sm:w-auto sm:justify-start ${
activeTab === 'scripts' activeTab === "scripts"
? 'bg-accent text-accent-foreground rounded-t-md rounded-b-none' ? "bg-accent text-accent-foreground rounded-t-md rounded-b-none"
: 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none' : "hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none"
}`}> }`}
>
<Package className="h-4 w-4" /> <Package className="h-4 w-4" />
<span className="hidden sm:inline">Available Scripts</span> <span className="hidden sm:inline">Available Scripts</span>
<span className="sm:hidden">Available</span> <span className="sm:hidden">Available</span>
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full"> <span className="bg-muted text-muted-foreground ml-1 rounded-full px-2 py-0.5 text-xs">
{scriptCounts.available} {scriptCounts.available}
</span> </span>
<ContextualHelpIcon section="available-scripts" tooltip="Help with Available Scripts" /> <ContextualHelpIcon
section="available-scripts"
tooltip="Help with Available Scripts"
/>
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="null" size="null"
onClick={() => setActiveTab('downloaded')} onClick={() => setActiveTab("downloaded")}
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${ className={`flex w-full items-center justify-center gap-2 px-3 py-2 text-sm sm:w-auto sm:justify-start ${
activeTab === 'downloaded' activeTab === "downloaded"
? 'bg-accent text-accent-foreground rounded-t-md rounded-b-none' ? "bg-accent text-accent-foreground rounded-t-md rounded-b-none"
: 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none' : "hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none"
}`}> }`}
>
<HardDrive className="h-4 w-4" /> <HardDrive className="h-4 w-4" />
<span className="hidden sm:inline">Downloaded Scripts</span> <span className="hidden sm:inline">Downloaded Scripts</span>
<span className="sm:hidden">Downloaded</span> <span className="sm:hidden">Downloaded</span>
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full"> <span className="bg-muted text-muted-foreground ml-1 rounded-full px-2 py-0.5 text-xs">
{scriptCounts.downloaded} {scriptCounts.downloaded}
</span> </span>
<ContextualHelpIcon section="downloaded-scripts" tooltip="Help with Downloaded Scripts" /> <ContextualHelpIcon
section="downloaded-scripts"
tooltip="Help with Downloaded Scripts"
/>
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="null" size="null"
onClick={() => setActiveTab('installed')} onClick={() => setActiveTab("installed")}
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${ className={`flex w-full items-center justify-center gap-2 px-3 py-2 text-sm sm:w-auto sm:justify-start ${
activeTab === 'installed' activeTab === "installed"
? 'bg-accent text-accent-foreground rounded-t-md rounded-b-none' ? "bg-accent text-accent-foreground rounded-t-md rounded-b-none"
: 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none' : "hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none"
}`}> }`}
>
<FolderOpen className="h-4 w-4" /> <FolderOpen className="h-4 w-4" />
<span className="hidden sm:inline">Installed Scripts</span> <span className="hidden sm:inline">Installed Scripts</span>
<span className="sm:hidden">Installed</span> <span className="sm:hidden">Installed</span>
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full"> <span className="bg-muted text-muted-foreground ml-1 rounded-full px-2 py-0.5 text-xs">
{scriptCounts.installed} {scriptCounts.installed}
</span> </span>
<ContextualHelpIcon section="installed-scripts" tooltip="Help with Installed Scripts" /> <ContextualHelpIcon
section="installed-scripts"
tooltip="Help with Installed Scripts"
/>
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="null" size="null"
onClick={() => setActiveTab('backups')} onClick={() => setActiveTab("backups")}
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${ className={`flex w-full items-center justify-center gap-2 px-3 py-2 text-sm sm:w-auto sm:justify-start ${
activeTab === 'backups' activeTab === "backups"
? 'bg-accent text-accent-foreground rounded-t-md rounded-b-none' ? "bg-accent text-accent-foreground rounded-t-md rounded-b-none"
: 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none' : "hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none"
}`}> }`}
>
<Archive className="h-4 w-4" /> <Archive className="h-4 w-4" />
<span className="hidden sm:inline">Backups</span> <span className="hidden sm:inline">Backups</span>
<span className="sm:hidden">Backups</span> <span className="sm:hidden">Backups</span>
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full"> <span className="bg-muted text-muted-foreground ml-1 rounded-full px-2 py-0.5 text-xs">
{scriptCounts.backups} {scriptCounts.backups}
</span> </span>
</Button> </Button>
@@ -296,8 +344,6 @@ export default function Home() {
</div> </div>
</div> </div>
{/* Running Script Terminal */} {/* Running Script Terminal */}
{runningScript && ( {runningScript && (
<div ref={terminalRef} className="mb-8"> <div ref={terminalRef} className="mb-8">
@@ -311,21 +357,17 @@ export default function Home() {
)} )}
{/* Tab Content */} {/* Tab Content */}
{activeTab === 'scripts' && ( {activeTab === "scripts" && (
<ScriptsGrid onInstallScript={handleRunScript} /> <ScriptsGrid onInstallScript={handleRunScript} />
)} )}
{activeTab === 'downloaded' && ( {activeTab === "downloaded" && (
<DownloadedScriptsTab onInstallScript={handleRunScript} /> <DownloadedScriptsTab onInstallScript={handleRunScript} />
)} )}
{activeTab === 'installed' && ( {activeTab === "installed" && <InstalledScriptsTab />}
<InstalledScriptsTab />
)}
{activeTab === 'backups' && ( {activeTab === "backups" && <BackupsTab />}
<BackupsTab />
)}
</div> </div>
{/* Footer */} {/* Footer */}