fix: ESLint/TypeScript fixes - nullish coalescing, regexp-exec, optional-chain, unescaped-entities, unused-vars, type-safety
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { Button } from './ui/button';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||
import { Button } from "./ui/button";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { useRegisterModal } from "./modal/ModalStackProvider";
|
||||
|
||||
interface BackupWarningModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -13,33 +13,43 @@ interface BackupWarningModalProps {
|
||||
export function BackupWarningModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onProceed
|
||||
onProceed,
|
||||
}: BackupWarningModalProps) {
|
||||
useRegisterModal(isOpen, { id: 'backup-warning-modal', allowEscape: true, onClose });
|
||||
useRegisterModal(isOpen, {
|
||||
id: "backup-warning-modal",
|
||||
allowEscape: true,
|
||||
onClose,
|
||||
});
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
|
||||
<div className="bg-card border-border w-full max-w-md rounded-lg border shadow-xl">
|
||||
{/* 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">
|
||||
<AlertTriangle className="h-8 w-8 text-warning" />
|
||||
<h2 className="text-2xl font-bold text-card-foreground">Backup Failed</h2>
|
||||
<AlertTriangle className="text-warning h-8 w-8" />
|
||||
<h2 className="text-card-foreground text-2xl font-bold">
|
||||
Backup Failed
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
The backup failed, but you can still proceed with the update if you wish.
|
||||
<br /><br />
|
||||
<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.
|
||||
<p className="text-muted-foreground mb-6 text-sm">
|
||||
The backup failed, but you can still proceed with the update if you
|
||||
wish.
|
||||
<br />
|
||||
<br />
|
||||
<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.
|
||||
</p>
|
||||
|
||||
{/* 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
|
||||
onClick={onClose}
|
||||
variant="outline"
|
||||
@@ -52,7 +62,7 @@ export function BackupWarningModal({
|
||||
onClick={onProceed}
|
||||
variant="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
|
||||
</Button>
|
||||
@@ -62,6 +72,3 @@ export function BackupWarningModal({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api } from '~/trpc/react';
|
||||
import { Button } from './ui/button';
|
||||
import { Badge } from './ui/badge';
|
||||
import { RefreshCw, ChevronDown, ChevronRight, HardDrive, Database, Server, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import { useState, useEffect } from "react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { Button } from "./ui/button";
|
||||
import { Badge } from "./ui/badge";
|
||||
import {
|
||||
RefreshCw,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
HardDrive,
|
||||
Database,
|
||||
Server,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from './ui/dropdown-menu';
|
||||
import { ConfirmationModal } from './ConfirmationModal';
|
||||
import { LoadingModal } from './LoadingModal';
|
||||
} from "./ui/dropdown-menu";
|
||||
import { ConfirmationModal } from "./ConfirmationModal";
|
||||
import { LoadingModal } from "./LoadingModal";
|
||||
|
||||
interface Backup {
|
||||
id: number;
|
||||
@@ -35,16 +44,25 @@ interface ContainerBackups {
|
||||
}
|
||||
|
||||
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 [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 [restoreSuccess, setRestoreSuccess] = useState(false);
|
||||
const [restoreError, setRestoreError] = useState<string | null>(null);
|
||||
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({
|
||||
onSuccess: () => {
|
||||
void refetchBackups();
|
||||
@@ -52,26 +70,30 @@ export function BackupsTab() {
|
||||
});
|
||||
|
||||
// Poll for restore progress
|
||||
const { data: restoreLogsData } = api.backups.getRestoreProgress.useQuery(undefined, {
|
||||
enabled: shouldPollRestore,
|
||||
refetchInterval: 1000, // Poll every second
|
||||
refetchIntervalInBackground: true,
|
||||
});
|
||||
const { data: restoreLogsData } = api.backups.getRestoreProgress.useQuery(
|
||||
undefined,
|
||||
{
|
||||
enabled: shouldPollRestore,
|
||||
refetchInterval: 1000, // Poll every second
|
||||
refetchIntervalInBackground: true,
|
||||
},
|
||||
);
|
||||
|
||||
// Update restore progress when log data changes
|
||||
useEffect(() => {
|
||||
if (restoreLogsData?.success && restoreLogsData.logs) {
|
||||
setRestoreProgress(restoreLogsData.logs);
|
||||
|
||||
|
||||
// Stop polling when restore is complete
|
||||
if (restoreLogsData.isComplete) {
|
||||
setShouldPollRestore(false);
|
||||
// Check if restore was successful or failed
|
||||
const lastLog = restoreLogsData.logs[restoreLogsData.logs.length - 1] || '';
|
||||
if (lastLog.includes('Restore completed successfully')) {
|
||||
const lastLog =
|
||||
restoreLogsData.logs[restoreLogsData.logs.length - 1] || "";
|
||||
if (lastLog.includes("Restore completed successfully")) {
|
||||
setRestoreSuccess(true);
|
||||
setRestoreError(null);
|
||||
} else if (lastLog.includes('Error:') || lastLog.includes('failed')) {
|
||||
} else if (lastLog.includes("Error:") || lastLog.includes("failed")) {
|
||||
setRestoreError(lastLog);
|
||||
setRestoreSuccess(false);
|
||||
}
|
||||
@@ -83,17 +105,22 @@ export function BackupsTab() {
|
||||
onMutate: () => {
|
||||
// Start polling for progress
|
||||
setShouldPollRestore(true);
|
||||
setRestoreProgress(['Starting restore...']);
|
||||
setRestoreProgress(["Starting restore..."]);
|
||||
setRestoreError(null);
|
||||
setRestoreSuccess(false);
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
// Stop polling - progress will be updated from logs
|
||||
setShouldPollRestore(false);
|
||||
|
||||
|
||||
if (result.success) {
|
||||
// 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);
|
||||
setRestoreSuccess(true);
|
||||
setRestoreError(null);
|
||||
@@ -101,8 +128,10 @@ export function BackupsTab() {
|
||||
setSelectedBackup(null);
|
||||
// Keep success message visible - user can dismiss manually
|
||||
} else {
|
||||
setRestoreError(result.error || 'Restore failed');
|
||||
setRestoreProgress(result.progress?.map(p => p.message) || restoreProgress);
|
||||
setRestoreError(result.error || "Restore failed");
|
||||
setRestoreProgress(
|
||||
result.progress?.map((p) => p.message) || restoreProgress,
|
||||
);
|
||||
setRestoreSuccess(false);
|
||||
setRestoreConfirmOpen(false);
|
||||
setSelectedBackup(null);
|
||||
@@ -112,17 +141,18 @@ export function BackupsTab() {
|
||||
onError: (error) => {
|
||||
// Stop polling on error
|
||||
setShouldPollRestore(false);
|
||||
setRestoreError(error.message || 'Restore failed');
|
||||
setRestoreError(error.message || "Restore failed");
|
||||
setRestoreConfirmOpen(false);
|
||||
setSelectedBackup(null);
|
||||
setRestoreProgress([]);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
// Update progress text in modal based on current progress
|
||||
const currentProgressText = restoreProgress.length > 0
|
||||
? restoreProgress[restoreProgress.length - 1]
|
||||
: 'Restoring backup...';
|
||||
const currentProgressText =
|
||||
restoreProgress.length > 0
|
||||
? restoreProgress[restoreProgress.length - 1]
|
||||
: "Restoring backup...";
|
||||
|
||||
// Auto-discover backups when tab is first opened
|
||||
useEffect(() => {
|
||||
@@ -149,11 +179,11 @@ export function BackupsTab() {
|
||||
|
||||
const handleRestoreConfirm = () => {
|
||||
if (!selectedBackup) return;
|
||||
|
||||
|
||||
setRestoreConfirmOpen(false);
|
||||
setRestoreError(null);
|
||||
setRestoreSuccess(false);
|
||||
|
||||
|
||||
restoreMutation.mutate({
|
||||
backupId: selectedBackup.backup.id,
|
||||
containerId: selectedBackup.containerId,
|
||||
@@ -172,39 +202,41 @@ export function BackupsTab() {
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: bigint | null): string => {
|
||||
if (!bytes) return 'Unknown size';
|
||||
if (!bytes) return "Unknown size";
|
||||
const b = Number(bytes);
|
||||
if (b === 0) return '0 B';
|
||||
if (b === 0) return "0 B";
|
||||
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));
|
||||
return `${(b / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
const formatDate = (date: Date | null): string => {
|
||||
if (!date) return 'Unknown date';
|
||||
if (!date) return "Unknown date";
|
||||
return new Date(date).toLocaleString();
|
||||
};
|
||||
|
||||
const getStorageTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'pbs':
|
||||
case "pbs":
|
||||
return <Database className="h-4 w-4" />;
|
||||
case 'local':
|
||||
case "local":
|
||||
return <HardDrive className="h-4 w-4" />;
|
||||
default:
|
||||
return <Server className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStorageTypeBadgeVariant = (type: string): 'default' | 'secondary' | 'outline' => {
|
||||
const getStorageTypeBadgeVariant = (
|
||||
type: string,
|
||||
): "default" | "secondary" | "outline" => {
|
||||
switch (type) {
|
||||
case 'pbs':
|
||||
return 'default';
|
||||
case 'local':
|
||||
return 'secondary';
|
||||
case "pbs":
|
||||
return "default";
|
||||
case "local":
|
||||
return "secondary";
|
||||
default:
|
||||
return 'outline';
|
||||
return "outline";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -216,8 +248,8 @@ export function BackupsTab() {
|
||||
{/* Header with refresh button */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-foreground">Backups</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
<h2 className="text-foreground text-2xl font-bold">Backups</h2>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
Discovered backups grouped by container ID
|
||||
</p>
|
||||
</div>
|
||||
@@ -226,31 +258,38 @@ export function BackupsTab() {
|
||||
disabled={isDiscovering}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isDiscovering ? 'animate-spin' : ''}`} />
|
||||
{isDiscovering ? 'Discovering...' : 'Discover Backups'}
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${isDiscovering ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{isDiscovering ? "Discovering..." : "Discover Backups"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
{(isLoading || isDiscovering) && backups.length === 0 && (
|
||||
<div className="bg-card rounded-lg border border-border p-8 text-center">
|
||||
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-4 text-muted-foreground" />
|
||||
<div className="bg-card border-border rounded-lg border p-8 text-center">
|
||||
<RefreshCw className="text-muted-foreground mx-auto mb-4 h-8 w-8 animate-spin" />
|
||||
<p className="text-muted-foreground">
|
||||
{isDiscovering ? 'Discovering backups...' : 'Loading backups...'}
|
||||
{isDiscovering ? "Discovering backups..." : "Loading backups..."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!isLoading && !isDiscovering && backups.length === 0 && (
|
||||
<div className="bg-card rounded-lg border border-border p-8 text-center">
|
||||
<HardDrive className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">No backups found</h3>
|
||||
<div className="bg-card border-border rounded-lg border p-8 text-center">
|
||||
<HardDrive className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
|
||||
<h3 className="text-foreground mb-2 text-lg font-semibold">
|
||||
No backups found
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Click "Discover Backups" to scan for backups on your servers.
|
||||
Click "Discover Backups" to scan for backups on your
|
||||
servers.
|
||||
</p>
|
||||
<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
|
||||
</Button>
|
||||
</div>
|
||||
@@ -266,33 +305,35 @@ export function BackupsTab() {
|
||||
return (
|
||||
<div
|
||||
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 */}
|
||||
<button
|
||||
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 ? (
|
||||
<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="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-foreground">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-foreground font-semibold">
|
||||
CT {container.container_id}
|
||||
</span>
|
||||
{container.hostname && (
|
||||
<>
|
||||
<span className="text-muted-foreground">•</span>
|
||||
<span className="text-muted-foreground">{container.hostname}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{container.hostname}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{backupCount} {backupCount === 1 ? 'backup' : 'backups'}
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{backupCount} {backupCount === 1 ? "backup" : "backups"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -300,28 +341,30 @@ export function BackupsTab() {
|
||||
|
||||
{/* Container content - backups list */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border">
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="border-border border-t">
|
||||
<div className="space-y-3 p-4">
|
||||
{container.backups.map((backup) => (
|
||||
<div
|
||||
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-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span className="font-medium text-foreground break-all">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<span className="text-foreground font-medium break-all">
|
||||
{backup.backup_name}
|
||||
</span>
|
||||
<Badge
|
||||
variant={getStorageTypeBadgeVariant(backup.storage_type)}
|
||||
variant={getStorageTypeBadgeVariant(
|
||||
backup.storage_type,
|
||||
)}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{getStorageTypeIcon(backup.storage_type)}
|
||||
{backup.storage_name}
|
||||
</Badge>
|
||||
</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 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<HardDrive className="h-3 w-3" />
|
||||
@@ -339,7 +382,7 @@ export function BackupsTab() {
|
||||
)}
|
||||
</div>
|
||||
<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}
|
||||
</code>
|
||||
</div>
|
||||
@@ -350,14 +393,19 @@ export function BackupsTab() {
|
||||
<Button
|
||||
variant="outline"
|
||||
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
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-48 bg-card border-border">
|
||||
<DropdownMenuContent className="bg-card border-border w-48">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleRestoreClick(backup, container.container_id)}
|
||||
onClick={() =>
|
||||
handleRestoreClick(
|
||||
backup,
|
||||
container.container_id,
|
||||
)
|
||||
}
|
||||
disabled={restoreMutation.isPending}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20"
|
||||
>
|
||||
@@ -386,9 +434,9 @@ export function BackupsTab() {
|
||||
|
||||
{/* Error state */}
|
||||
{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">
|
||||
Error loading backups: {backupsData.error || 'Unknown error'}
|
||||
Error loading backups: {backupsData.error || "Unknown error"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -412,7 +460,8 @@ export function BackupsTab() {
|
||||
)}
|
||||
|
||||
{/* Restore Progress Modal */}
|
||||
{(restoreMutation.isPending || (restoreSuccess && restoreProgress.length > 0)) && (
|
||||
{(restoreMutation.isPending ||
|
||||
(restoreSuccess && restoreProgress.length > 0)) && (
|
||||
<LoadingModal
|
||||
isOpen={true}
|
||||
action={currentProgressText}
|
||||
@@ -428,11 +477,13 @@ export function BackupsTab() {
|
||||
|
||||
{/* Restore Success */}
|
||||
{restoreSuccess && (
|
||||
<div className="bg-success/10 border border-success/20 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="bg-success/10 border-success/20 rounded-lg border p-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-success" />
|
||||
<span className="font-medium text-success">Restore Completed Successfully</span>
|
||||
<CheckCircle className="text-success h-5 w-5" />
|
||||
<span className="text-success font-medium">
|
||||
Restore Completed Successfully
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -446,7 +497,7 @@ export function BackupsTab() {
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
The container has been restored from backup.
|
||||
</p>
|
||||
</div>
|
||||
@@ -454,11 +505,11 @@ export function BackupsTab() {
|
||||
|
||||
{/* Restore Error */}
|
||||
{restoreError && (
|
||||
<div className="bg-error/10 border border-error/20 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="bg-error/10 border-error/20 rounded-lg border p-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-error" />
|
||||
<span className="font-medium text-error">Restore Failed</span>
|
||||
<AlertCircle className="text-error h-5 w-5" />
|
||||
<span className="text-error font-medium">Restore Failed</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -472,13 +523,11 @@ export function BackupsTab() {
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{restoreError}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">{restoreError}</p>
|
||||
{restoreProgress.length > 0 && (
|
||||
<div className="space-y-1 mt-2">
|
||||
<div className="mt-2 space-y-1">
|
||||
{restoreProgress.map((message, index) => (
|
||||
<p key={index} className="text-sm text-muted-foreground">
|
||||
<p key={index} className="text-muted-foreground text-sm">
|
||||
{message}
|
||||
</p>
|
||||
))}
|
||||
@@ -500,4 +549,3 @@ export function BackupsTab() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,41 +1,53 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { api } from '~/trpc/react';
|
||||
import { ScriptCard } from './ScriptCard';
|
||||
import { ScriptCardList } from './ScriptCardList';
|
||||
import { ScriptDetailModal } from './ScriptDetailModal';
|
||||
import { CategorySidebar } from './CategorySidebar';
|
||||
import { FilterBar, type FilterState } from './FilterBar';
|
||||
import { ViewToggle } from './ViewToggle';
|
||||
import { Button } from './ui/button';
|
||||
import type { ScriptCard as ScriptCardType } from '~/types/script';
|
||||
import { getDefaultFilters, mergeFiltersWithDefaults } from './filterUtils';
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { ScriptCard } from "./ScriptCard";
|
||||
import { ScriptCardList } from "./ScriptCardList";
|
||||
import { ScriptDetailModal } from "./ScriptDetailModal";
|
||||
import { CategorySidebar } from "./CategorySidebar";
|
||||
import { FilterBar, type FilterState } from "./FilterBar";
|
||||
import { ViewToggle } from "./ViewToggle";
|
||||
import { Button } from "./ui/button";
|
||||
import type { ScriptCard as ScriptCardType } from "~/types/script";
|
||||
import type { Server } from "~/types/server";
|
||||
import { getDefaultFilters, mergeFiltersWithDefaults } from "./filterUtils";
|
||||
|
||||
interface DownloadedScriptsTabProps {
|
||||
onInstallScript?: (
|
||||
scriptPath: string,
|
||||
scriptName: string,
|
||||
mode?: "local" | "ssh",
|
||||
server?: any,
|
||||
server?: Server,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabProps) {
|
||||
export function DownloadedScriptsTab({
|
||||
onInstallScript,
|
||||
}: DownloadedScriptsTabProps) {
|
||||
const [selectedSlug, setSelectedSlug] = useState<string | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
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 [saveFiltersEnabled, setSaveFiltersEnabled] = useState(false);
|
||||
const [isLoadingFilters, setIsLoadingFilters] = useState(true);
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery();
|
||||
const { data: localScriptsData, isLoading: localLoading, error: localError } = api.scripts.getAllDownloadedScripts.useQuery();
|
||||
const {
|
||||
data: scriptCardsData,
|
||||
isLoading: githubLoading,
|
||||
error: githubError,
|
||||
refetch,
|
||||
} = api.scripts.getScriptCardsWithCategories.useQuery();
|
||||
const {
|
||||
data: localScriptsData,
|
||||
isLoading: localLoading,
|
||||
error: localError,
|
||||
} = api.scripts.getAllDownloadedScripts.useQuery();
|
||||
const { data: scriptData } = api.scripts.getScriptBySlug.useQuery(
|
||||
{ slug: selectedSlug ?? '' },
|
||||
{ enabled: !!selectedSlug }
|
||||
{ slug: selectedSlug ?? "" },
|
||||
{ enabled: !!selectedSlug },
|
||||
);
|
||||
|
||||
// Load SAVE_FILTER setting, saved filters, and view mode on component mount
|
||||
@@ -43,7 +55,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
// Load SAVE_FILTER setting
|
||||
const saveFilterResponse = await fetch('/api/settings/save-filter');
|
||||
const saveFilterResponse = await fetch("/api/settings/save-filter");
|
||||
let saveFilterEnabled = false;
|
||||
if (saveFilterResponse.ok) {
|
||||
const saveFilterData = await saveFilterResponse.json();
|
||||
@@ -53,7 +65,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
|
||||
// Load saved filters if SAVE_FILTER is enabled
|
||||
if (saveFilterEnabled) {
|
||||
const filtersResponse = await fetch('/api/settings/filters');
|
||||
const filtersResponse = await fetch("/api/settings/filters");
|
||||
if (filtersResponse.ok) {
|
||||
const filtersData = await filtersResponse.json();
|
||||
if (filtersData.filters) {
|
||||
@@ -63,16 +75,20 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
}
|
||||
|
||||
// Load view mode
|
||||
const viewModeResponse = await fetch('/api/settings/view-mode');
|
||||
const viewModeResponse = await fetch("/api/settings/view-mode");
|
||||
if (viewModeResponse.ok) {
|
||||
const viewModeData = await viewModeResponse.json();
|
||||
const viewMode = viewModeData.viewMode;
|
||||
if (viewMode && typeof viewMode === 'string' && (viewMode === 'card' || viewMode === 'list')) {
|
||||
if (
|
||||
viewMode &&
|
||||
typeof viewMode === "string" &&
|
||||
(viewMode === "card" || viewMode === "list")
|
||||
) {
|
||||
setViewMode(viewMode);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading settings:', error);
|
||||
console.error("Error loading settings:", error);
|
||||
} finally {
|
||||
setIsLoadingFilters(false);
|
||||
}
|
||||
@@ -87,15 +103,15 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
|
||||
const saveFilters = async () => {
|
||||
try {
|
||||
await fetch('/api/settings/filters', {
|
||||
method: 'POST',
|
||||
await fetch("/api/settings/filters", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ filters }),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error saving filters:', error);
|
||||
console.error("Error saving filters:", error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -110,15 +126,15 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
|
||||
const saveViewMode = async () => {
|
||||
try {
|
||||
await fetch('/api/settings/view-mode', {
|
||||
method: 'POST',
|
||||
await fetch("/api/settings/view-mode", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ viewMode }),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error saving view mode:', error);
|
||||
console.error("Error saving view mode:", error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -129,31 +145,32 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
|
||||
// Extract categories from metadata
|
||||
const categories = React.useMemo((): string[] => {
|
||||
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return [];
|
||||
|
||||
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories)
|
||||
return [];
|
||||
|
||||
return (scriptCardsData.metadata.categories as any[])
|
||||
.filter((cat) => cat.id !== 0) // Exclude Miscellaneous for main list
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
.map((cat) => cat.name as string)
|
||||
.filter((name): name is string => typeof name === 'string');
|
||||
.filter((name): name is string => typeof name === "string");
|
||||
}, [scriptCardsData]);
|
||||
|
||||
// Get GitHub scripts with download status (deduplicated)
|
||||
const combinedScripts = React.useMemo((): ScriptCardType[] => {
|
||||
if (!scriptCardsData?.success) return [];
|
||||
|
||||
|
||||
// Use Map to deduplicate by slug/name
|
||||
const scriptMap = new Map<string, ScriptCardType>();
|
||||
|
||||
scriptCardsData.cards?.forEach(script => {
|
||||
|
||||
scriptCardsData.cards?.forEach((script) => {
|
||||
if (script?.name && script?.slug) {
|
||||
// Use slug as unique identifier, only keep first occurrence
|
||||
if (!scriptMap.has(script.slug)) {
|
||||
scriptMap.set(script.slug, {
|
||||
...script,
|
||||
source: 'github' as const,
|
||||
source: "github" as const,
|
||||
isDownloaded: false, // Will be updated by status check
|
||||
isUpToDate: false, // Will be updated by status check
|
||||
isUpToDate: false, // Will be updated by status check
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -165,68 +182,77 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
// Update scripts with download status and filter to only downloaded scripts
|
||||
const downloadedScripts = React.useMemo((): ScriptCardType[] => {
|
||||
// Helper to normalize identifiers so underscores vs hyphens don't break matches
|
||||
const normalizeId = (s?: string): string => (s ?? '')
|
||||
.toLowerCase()
|
||||
.replace(/\.(sh|bash|py|js|ts)$/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
const normalizeId = (s?: string): string =>
|
||||
(s ?? "")
|
||||
.toLowerCase()
|
||||
.replace(/\.(sh|bash|py|js|ts)$/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
|
||||
return combinedScripts
|
||||
.map(script => {
|
||||
.map((script) => {
|
||||
if (!script?.name) {
|
||||
return script; // Return as-is if invalid
|
||||
}
|
||||
|
||||
|
||||
// Check if there's a corresponding local script
|
||||
const hasLocalVersion = localScriptsData?.scripts?.some(local => {
|
||||
if (!local?.name) return false;
|
||||
|
||||
// Primary: Exact slug-to-slug matching (most reliable, prevents false positives)
|
||||
if (local.slug && script.slug) {
|
||||
if (local.slug.toLowerCase() === script.slug.toLowerCase()) {
|
||||
return true;
|
||||
const hasLocalVersion =
|
||||
localScriptsData?.scripts?.some((local) => {
|
||||
if (!local?.name) return false;
|
||||
|
||||
// Primary: Exact slug-to-slug matching (most reliable, prevents false positives)
|
||||
if (local.slug && script.slug) {
|
||||
if (local.slug.toLowerCase() === script.slug.toLowerCase()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Secondary: Check install basenames (for edge cases where install script names differ from slugs)
|
||||
// Only use normalized matching for install basenames, not for slug/name matching
|
||||
const normalizedLocal = normalizeId(local.name);
|
||||
const matchesInstallBasename = (script as any)?.install_basenames?.some((base: string) => normalizeId(base) === normalizedLocal) ?? false;
|
||||
return matchesInstallBasename;
|
||||
}) ?? false;
|
||||
|
||||
|
||||
// Secondary: Check install basenames (for edge cases where install script names differ from slugs)
|
||||
// Only use normalized matching for install basenames, not for slug/name matching
|
||||
const normalizedLocal = normalizeId(local.name);
|
||||
const matchesInstallBasename =
|
||||
(script as any)?.install_basenames?.some(
|
||||
(base: string) => normalizeId(base) === normalizedLocal,
|
||||
) ?? false;
|
||||
return matchesInstallBasename;
|
||||
}) ?? false;
|
||||
|
||||
return {
|
||||
...script,
|
||||
isDownloaded: hasLocalVersion,
|
||||
};
|
||||
})
|
||||
.filter(script => script.isDownloaded); // Only show downloaded scripts
|
||||
.filter((script) => script.isDownloaded); // Only show downloaded scripts
|
||||
}, [combinedScripts, localScriptsData]);
|
||||
|
||||
// Count scripts per category (using downloaded scripts only)
|
||||
const categoryCounts = React.useMemo((): Record<string, number> => {
|
||||
if (!scriptCardsData?.success) return {};
|
||||
|
||||
|
||||
const counts: Record<string, number> = {};
|
||||
|
||||
|
||||
// Initialize all categories with 0
|
||||
categories.forEach((categoryName: string) => {
|
||||
counts[categoryName] = 0;
|
||||
});
|
||||
|
||||
|
||||
// Count each unique downloaded script only once per category
|
||||
downloadedScripts.forEach(script => {
|
||||
downloadedScripts.forEach((script) => {
|
||||
if (script.categoryNames && script.slug) {
|
||||
const countedCategories = new Set<string>();
|
||||
script.categoryNames.forEach((categoryName: unknown) => {
|
||||
if (typeof categoryName === 'string' && counts[categoryName] !== undefined && !countedCategories.has(categoryName)) {
|
||||
if (
|
||||
typeof categoryName === "string" &&
|
||||
counts[categoryName] !== undefined &&
|
||||
!countedCategories.has(categoryName)
|
||||
) {
|
||||
countedCategories.add(categoryName);
|
||||
counts[categoryName]++;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return counts;
|
||||
}, [categories, downloadedScripts, scriptCardsData?.success]);
|
||||
|
||||
@@ -237,15 +263,15 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
// Filter by search query
|
||||
if (filters.searchQuery?.trim()) {
|
||||
const query = filters.searchQuery.toLowerCase().trim();
|
||||
|
||||
|
||||
if (query.length >= 1) {
|
||||
scripts = scripts.filter(script => {
|
||||
if (!script || typeof script !== 'object') {
|
||||
scripts = scripts.filter((script) => {
|
||||
if (!script || typeof script !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const name = (script.name ?? '').toLowerCase();
|
||||
const slug = (script.slug ?? '').toLowerCase();
|
||||
const name = (script.name ?? "").toLowerCase();
|
||||
const slug = (script.slug ?? "").toLowerCase();
|
||||
|
||||
return name.includes(query) ?? slug.includes(query);
|
||||
});
|
||||
@@ -254,9 +280,9 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
|
||||
// Filter by category using real category data from downloaded scripts
|
||||
if (selectedCategory) {
|
||||
scripts = scripts.filter(script => {
|
||||
scripts = scripts.filter((script) => {
|
||||
if (!script) return false;
|
||||
|
||||
|
||||
// Check if the downloaded script has categoryNames that include the selected category
|
||||
return script.categoryNames?.includes(selectedCategory) ?? false;
|
||||
});
|
||||
@@ -264,7 +290,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
|
||||
// Filter by updateable status
|
||||
if (filters.showUpdatable !== null) {
|
||||
scripts = scripts.filter(script => {
|
||||
scripts = scripts.filter((script) => {
|
||||
if (!script) return false;
|
||||
const isUpdatable = script.updateable ?? false;
|
||||
return filters.showUpdatable ? isUpdatable : !isUpdatable;
|
||||
@@ -273,28 +299,30 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
|
||||
// Filter by script types
|
||||
if (filters.selectedTypes.length > 0) {
|
||||
scripts = scripts.filter(script => {
|
||||
scripts = scripts.filter((script) => {
|
||||
if (!script) return false;
|
||||
const scriptType = (script.type ?? '').toLowerCase();
|
||||
|
||||
const scriptType = (script.type ?? "").toLowerCase();
|
||||
|
||||
// Map non-standard types to standard categories
|
||||
const mappedType = scriptType === 'turnkey' ? 'ct' : scriptType;
|
||||
|
||||
return filters.selectedTypes.some(type => type.toLowerCase() === mappedType);
|
||||
const mappedType = scriptType === "turnkey" ? "ct" : scriptType;
|
||||
|
||||
return filters.selectedTypes.some(
|
||||
(type) => type.toLowerCase() === mappedType,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by repositories
|
||||
if (filters.selectedRepositories.length > 0) {
|
||||
scripts = scripts.filter(script => {
|
||||
scripts = scripts.filter((script) => {
|
||||
if (!script) return false;
|
||||
const repoUrl = script.repository_url;
|
||||
|
||||
|
||||
// If script has no repository_url, exclude it when filtering by repositories
|
||||
if (!repoUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// Only include scripts from selected repositories
|
||||
return filters.selectedRepositories.includes(repoUrl);
|
||||
});
|
||||
@@ -303,18 +331,18 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
// Apply sorting
|
||||
scripts.sort((a, b) => {
|
||||
if (!a || !b) return 0;
|
||||
|
||||
|
||||
let compareValue = 0;
|
||||
|
||||
|
||||
switch (filters.sortBy) {
|
||||
case 'name':
|
||||
compareValue = (a.name ?? '').localeCompare(b.name ?? '');
|
||||
case "name":
|
||||
compareValue = (a.name ?? "").localeCompare(b.name ?? "");
|
||||
break;
|
||||
case 'created':
|
||||
case "created":
|
||||
// Get creation date from script metadata in JSON format (date_created: "YYYY-MM-DD")
|
||||
const aCreated = a?.date_created ?? '';
|
||||
const bCreated = b?.date_created ?? '';
|
||||
|
||||
const aCreated = a?.date_created ?? "";
|
||||
const bCreated = b?.date_created ?? "";
|
||||
|
||||
// If both have dates, compare them directly
|
||||
if (aCreated && bCreated) {
|
||||
// For dates: asc = oldest first (2020 before 2024), desc = newest first (2024 before 2020)
|
||||
@@ -327,15 +355,15 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
compareValue = 1;
|
||||
} else {
|
||||
// Both have no dates, fallback to name comparison
|
||||
compareValue = (a.name ?? '').localeCompare(b.name ?? '');
|
||||
compareValue = (a.name ?? "").localeCompare(b.name ?? "");
|
||||
}
|
||||
break;
|
||||
default:
|
||||
compareValue = (a.name ?? '').localeCompare(b.name ?? '');
|
||||
compareValue = (a.name ?? "").localeCompare(b.name ?? "");
|
||||
}
|
||||
|
||||
|
||||
// Apply sort order
|
||||
return filters.sortOrder === 'asc' ? compareValue : -compareValue;
|
||||
return filters.sortOrder === "asc" ? compareValue : -compareValue;
|
||||
});
|
||||
|
||||
return scripts;
|
||||
@@ -343,8 +371,10 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
|
||||
// Calculate filter counts for FilterBar
|
||||
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 };
|
||||
}, [downloadedScripts]);
|
||||
|
||||
@@ -362,13 +392,13 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
useEffect(() => {
|
||||
if (selectedCategory && gridRef.current) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
gridRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
inline: 'nearest'
|
||||
gridRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
inline: "nearest",
|
||||
});
|
||||
}, 100);
|
||||
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [selectedCategory]);
|
||||
@@ -387,22 +417,38 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
if (githubLoading || localLoading) {
|
||||
return (
|
||||
<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>
|
||||
<span className="ml-2 text-muted-foreground">Loading downloaded scripts...</span>
|
||||
<div className="border-primary h-8 w-8 animate-spin rounded-full border-b-2"></div>
|
||||
<span className="text-muted-foreground ml-2">
|
||||
Loading downloaded scripts...
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (githubError || localError) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="py-12 text-center">
|
||||
<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">
|
||||
<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
|
||||
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>
|
||||
<p className="text-lg font-medium">Failed to load downloaded scripts</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{githubError?.message ?? localError?.message ?? 'Unknown error occurred'}
|
||||
<p className="text-lg font-medium">
|
||||
Failed to load downloaded scripts
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{githubError?.message ??
|
||||
localError?.message ??
|
||||
"Unknown error occurred"}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -419,14 +465,25 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
|
||||
if (!downloadedScripts?.length) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="py-12 text-center">
|
||||
<div className="text-muted-foreground">
|
||||
<svg className="w-12 h-12 mx-auto mb-4" 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
|
||||
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>
|
||||
<p className="text-lg font-medium">No downloaded scripts found</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
You haven't downloaded any scripts yet. Visit the Available Scripts tab to download some scripts.
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
You haven't downloaded any scripts yet. Visit the Available
|
||||
Scripts tab to download some scripts.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -435,12 +492,9 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:gap-6">
|
||||
{/* Category Sidebar */}
|
||||
<div className="flex-shrink-0 order-2 lg:order-1">
|
||||
<div className="order-2 flex-shrink-0 lg:order-1">
|
||||
<CategorySidebar
|
||||
categories={categories}
|
||||
categoryCounts={categoryCounts}
|
||||
@@ -451,7 +505,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
</div>
|
||||
|
||||
{/* 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 */}
|
||||
<FilterBar
|
||||
filters={filters}
|
||||
@@ -464,26 +518,41 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
/>
|
||||
|
||||
{/* View Toggle */}
|
||||
<ViewToggle
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
<ViewToggle viewMode={viewMode} onViewModeChange={setViewMode} />
|
||||
|
||||
{/* Scripts Grid */}
|
||||
{filteredScripts.length === 0 && (filters.searchQuery || selectedCategory || filters.showUpdatable !== null || filters.selectedTypes.length > 0) ? (
|
||||
<div className="text-center py-12">
|
||||
{filteredScripts.length === 0 &&
|
||||
(filters.searchQuery ||
|
||||
selectedCategory ||
|
||||
filters.showUpdatable !== null ||
|
||||
filters.selectedTypes.length > 0) ? (
|
||||
<div className="py-12 text-center">
|
||||
<div className="text-muted-foreground">
|
||||
<svg className="w-12 h-12 mx-auto mb-4" 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
|
||||
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>
|
||||
<p className="text-lg font-medium">No matching downloaded scripts found</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
<p className="text-lg font-medium">
|
||||
No matching downloaded scripts found
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
Try different filter settings or clear all filters.
|
||||
</p>
|
||||
<div className="flex justify-center gap-2 mt-4">
|
||||
<div className="mt-4 flex justify-center gap-2">
|
||||
{filters.searchQuery && (
|
||||
<Button
|
||||
onClick={() => handleFiltersChange({ ...filters, searchQuery: '' })}
|
||||
onClick={() =>
|
||||
handleFiltersChange({ ...filters, searchQuery: "" })
|
||||
}
|
||||
variant="default"
|
||||
size="default"
|
||||
>
|
||||
@@ -502,18 +571,17 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
viewMode === 'card' ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{filteredScripts.map((script, index) => {
|
||||
) : viewMode === "card" ? (
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{filteredScripts.map((script, index) => {
|
||||
// Add validation to ensure script has required properties
|
||||
if (!script || typeof script !== 'object') {
|
||||
if (!script || typeof script !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// Create a unique key by combining slug, name, and index to handle duplicates
|
||||
const uniqueKey = `${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
|
||||
|
||||
const uniqueKey = `${script.slug ?? "unknown"}-${script.name ?? "unnamed"}-${index}`;
|
||||
|
||||
return (
|
||||
<ScriptCard
|
||||
key={uniqueKey}
|
||||
@@ -522,18 +590,18 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredScripts.map((script, index) => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredScripts.map((script, index) => {
|
||||
// Add validation to ensure script has required properties
|
||||
if (!script || typeof script !== 'object') {
|
||||
if (!script || typeof script !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// Create a unique key by combining slug, name, and index to handle duplicates
|
||||
const uniqueKey = `${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
|
||||
|
||||
const uniqueKey = `${script.slug ?? "unknown"}-${script.name ?? "unnamed"}-${index}`;
|
||||
|
||||
return (
|
||||
<ScriptCardList
|
||||
key={uniqueKey}
|
||||
@@ -542,8 +610,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ScriptDetailModal
|
||||
|
||||
@@ -3,7 +3,17 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "./ui/button";
|
||||
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 { getDefaultFilters } from "./filterUtils";
|
||||
|
||||
@@ -49,11 +59,11 @@ export function FilterBar({
|
||||
// Fetch enabled repositories
|
||||
const { data: enabledReposData } = api.repositories.getEnabled.useQuery();
|
||||
const enabledRepos = enabledReposData?.repositories ?? [];
|
||||
|
||||
|
||||
// Helper function to extract repository name from URL
|
||||
const getRepoName = (url: string): string => {
|
||||
try {
|
||||
const match = url.match(/github\.com\/([^\/]+)\/([^\/]+)/);
|
||||
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(url);
|
||||
if (match) {
|
||||
return `${match[1]}/${match[2]}`;
|
||||
}
|
||||
@@ -98,29 +108,33 @@ export function FilterBar({
|
||||
};
|
||||
|
||||
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 */}
|
||||
{isLoadingFilters && (
|
||||
<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="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
|
||||
<div className="text-muted-foreground flex items-center space-x-2 text-sm">
|
||||
<div className="border-primary h-4 w-4 animate-spin rounded-full border-2 border-t-transparent"></div>
|
||||
<span>Loading saved filters...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Filter Header */}
|
||||
{!isLoadingFilters && (
|
||||
<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">
|
||||
<ContextualHelpIcon section="available-scripts" tooltip="Help with filtering and searching" />
|
||||
<ContextualHelpIcon
|
||||
section="available-scripts"
|
||||
tooltip="Help with filtering and searching"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => setIsMinimized(!isMinimized)}
|
||||
variant="ghost"
|
||||
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"}
|
||||
>
|
||||
<svg
|
||||
@@ -146,10 +160,10 @@ export function FilterBar({
|
||||
<>
|
||||
{/* Search Bar */}
|
||||
<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">
|
||||
<svg
|
||||
className="h-5 w-5 text-muted-foreground"
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -167,13 +181,13 @@ export function FilterBar({
|
||||
placeholder="Search scripts..."
|
||||
value={filters.searchQuery}
|
||||
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 && (
|
||||
<Button
|
||||
onClick={() => updateFilters({ searchQuery: "" })}
|
||||
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
|
||||
className="h-5 w-5"
|
||||
@@ -194,318 +208,335 @@ export function FilterBar({
|
||||
</div>
|
||||
|
||||
{/* Filter Buttons */}
|
||||
<div className="mb-4 flex flex-col sm:flex-row flex-wrap gap-2 sm:gap-3">
|
||||
{/* Updateable Filter */}
|
||||
<Button
|
||||
onClick={() => {
|
||||
const next =
|
||||
filters.showUpdatable === null
|
||||
? true
|
||||
: filters.showUpdatable === true
|
||||
? false
|
||||
: null;
|
||||
updateFilters({ showUpdatable: next });
|
||||
}}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className={`w-full sm:w-auto flex items-center justify-center space-x-2 ${
|
||||
filters.showUpdatable === null
|
||||
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
: filters.showUpdatable === true
|
||||
? "border border-success/20 bg-success/10 text-success"
|
||||
: "border border-destructive/20 bg-destructive/10 text-destructive"
|
||||
}`}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
<span>{getUpdatableButtonText()}</span>
|
||||
</Button>
|
||||
|
||||
{/* Type Dropdown */}
|
||||
<div className="relative w-full sm:w-auto">
|
||||
<Button
|
||||
onClick={() => setIsTypeDropdownOpen(!isTypeDropdownOpen)}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className={`w-full flex items-center justify-center space-x-2 ${
|
||||
filters.selectedTypes.length === 0
|
||||
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
: "border border-primary/20 bg-primary/10 text-primary"
|
||||
}`}
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
<span>{getTypeButtonText()}</span>
|
||||
<svg
|
||||
className={`h-4 w-4 transition-transform ${isTypeDropdownOpen ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
|
||||
{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="p-2">
|
||||
{SCRIPT_TYPES.map((type) => {
|
||||
const IconComponent = type.Icon;
|
||||
return (
|
||||
<label
|
||||
key={type.value}
|
||||
className="flex cursor-pointer items-center space-x-3 rounded-md px-3 py-2 hover:bg-accent"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.selectedTypes.includes(type.value)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
updateFilters({
|
||||
selectedTypes: [
|
||||
...filters.selectedTypes,
|
||||
type.value,
|
||||
],
|
||||
});
|
||||
} else {
|
||||
updateFilters({
|
||||
selectedTypes: filters.selectedTypes.filter(
|
||||
(t) => t !== type.value,
|
||||
),
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="rounded border-input text-primary focus:ring-primary"
|
||||
/>
|
||||
<IconComponent className="h-4 w-4" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{type.label}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="border-t border-border p-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
updateFilters({ selectedTypes: [] });
|
||||
setIsTypeDropdownOpen(false);
|
||||
}}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Repository Filter Buttons - Only show if more than one enabled repo */}
|
||||
{enabledRepos.length > 1 && enabledRepos.map((repo) => {
|
||||
const isSelected = filters.selectedRepositories.includes(repo.url);
|
||||
return (
|
||||
<div className="mb-4 flex flex-col flex-wrap gap-2 sm:flex-row sm:gap-3">
|
||||
{/* Updateable Filter */}
|
||||
<Button
|
||||
key={repo.id}
|
||||
onClick={() => {
|
||||
const currentSelected = filters.selectedRepositories;
|
||||
if (isSelected) {
|
||||
// Remove repository from selection
|
||||
updateFilters({
|
||||
selectedRepositories: currentSelected.filter(url => url !== repo.url)
|
||||
});
|
||||
} else {
|
||||
// Add repository to selection
|
||||
updateFilters({
|
||||
selectedRepositories: [...currentSelected, repo.url]
|
||||
});
|
||||
}
|
||||
const next =
|
||||
filters.showUpdatable === null
|
||||
? true
|
||||
: filters.showUpdatable === true
|
||||
? false
|
||||
: null;
|
||||
updateFilters({ showUpdatable: next });
|
||||
}}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className={`w-full sm:w-auto flex items-center justify-center space-x-2 ${
|
||||
isSelected
|
||||
? "border border-primary/20 bg-primary/10 text-primary"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
className={`flex w-full items-center justify-center space-x-2 sm:w-auto ${
|
||||
filters.showUpdatable === null
|
||||
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
: filters.showUpdatable === true
|
||||
? "border-success/20 bg-success/10 text-success border"
|
||||
: "border-destructive/20 bg-destructive/10 text-destructive border"
|
||||
}`}
|
||||
>
|
||||
<GitBranch className="h-4 w-4" />
|
||||
<span>{getRepoName(repo.url)}</span>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
<span>{getUpdatableButtonText()}</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Sort By Dropdown */}
|
||||
<div className="relative w-full sm:w-auto">
|
||||
<Button
|
||||
onClick={() => setIsSortDropdownOpen(!isSortDropdownOpen)}
|
||||
variant="outline"
|
||||
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"
|
||||
>
|
||||
{filters.sortBy === "name" ? (
|
||||
<FileText className="h-4 w-4" />
|
||||
) : (
|
||||
<Calendar className="h-4 w-4" />
|
||||
)}
|
||||
<span>{filters.sortBy === "name" ? "By Name" : "By Created Date"}</span>
|
||||
<svg
|
||||
className={`h-4 w-4 transition-transform ${isSortDropdownOpen ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
{/* Type Dropdown */}
|
||||
<div className="relative w-full sm:w-auto">
|
||||
<Button
|
||||
onClick={() => setIsTypeDropdownOpen(!isTypeDropdownOpen)}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className={`flex w-full items-center justify-center space-x-2 ${
|
||||
filters.selectedTypes.length === 0
|
||||
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
: "border-primary/20 bg-primary/10 text-primary border"
|
||||
}`}
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
<span>{getTypeButtonText()}</span>
|
||||
<svg
|
||||
className={`h-4 w-4 transition-transform ${isTypeDropdownOpen ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
|
||||
{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="p-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
updateFilters({ sortBy: "name" });
|
||||
setIsSortDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full flex items-center space-x-3 rounded-md px-3 py-2 text-left hover:bg-accent ${
|
||||
filters.sortBy === "name" ? "bg-primary/10 text-primary" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="text-sm">By Name</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
updateFilters({ sortBy: "created" });
|
||||
setIsSortDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full flex items-center space-x-3 rounded-md px-3 py-2 text-left hover:bg-accent ${
|
||||
filters.sortBy === "created" ? "bg-primary/10 text-primary" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span className="text-sm">By Created Date</span>
|
||||
</button>
|
||||
</div>
|
||||
{isTypeDropdownOpen && (
|
||||
<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">
|
||||
{SCRIPT_TYPES.map((type) => {
|
||||
const IconComponent = type.Icon;
|
||||
return (
|
||||
<label
|
||||
key={type.value}
|
||||
className="hover:bg-accent flex cursor-pointer items-center space-x-3 rounded-md px-3 py-2"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.selectedTypes.includes(type.value)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
updateFilters({
|
||||
selectedTypes: [
|
||||
...filters.selectedTypes,
|
||||
type.value,
|
||||
],
|
||||
});
|
||||
} else {
|
||||
updateFilters({
|
||||
selectedTypes: filters.selectedTypes.filter(
|
||||
(t) => t !== type.value,
|
||||
),
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="border-input text-primary focus:ring-primary rounded"
|
||||
/>
|
||||
<IconComponent className="h-4 w-4" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{type.label}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="border-border border-t p-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
updateFilters({ selectedTypes: [] });
|
||||
setIsTypeDropdownOpen(false);
|
||||
}}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:bg-accent hover:text-foreground w-full justify-start"
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sort Order Button */}
|
||||
<Button
|
||||
onClick={() =>
|
||||
updateFilters({
|
||||
sortOrder: filters.sortOrder === "asc" ? "desc" : "asc",
|
||||
})
|
||||
}
|
||||
variant="outline"
|
||||
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"
|
||||
>
|
||||
{filters.sortOrder === "asc" ? (
|
||||
<>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 11l5-5m0 0l5 5m-5-5v12"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
{filters.sortBy === "created" ? "Oldest First" : "A-Z"}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 13l-5 5m0 0l-5-5m5 5V6"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
{filters.sortBy === "created" ? "Newest First" : "Z-A"}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{/* Repository Filter Buttons - Only show if more than one enabled repo */}
|
||||
{enabledRepos.length > 1 &&
|
||||
enabledRepos.map((repo) => {
|
||||
const isSelected = filters.selectedRepositories.includes(
|
||||
repo.url,
|
||||
);
|
||||
return (
|
||||
<Button
|
||||
key={repo.id}
|
||||
onClick={() => {
|
||||
const currentSelected = filters.selectedRepositories;
|
||||
if (isSelected) {
|
||||
// Remove repository from selection
|
||||
updateFilters({
|
||||
selectedRepositories: currentSelected.filter(
|
||||
(url) => url !== repo.url,
|
||||
),
|
||||
});
|
||||
} else {
|
||||
// Add repository to selection
|
||||
updateFilters({
|
||||
selectedRepositories: [...currentSelected, repo.url],
|
||||
});
|
||||
}
|
||||
}}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className={`flex w-full items-center justify-center space-x-2 sm:w-auto ${
|
||||
isSelected
|
||||
? "border-primary/20 bg-primary/10 text-primary border"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
}`}
|
||||
>
|
||||
<GitBranch className="h-4 w-4" />
|
||||
<span>{getRepoName(repo.url)}</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 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 items-center gap-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{filteredCount === totalScripts ? (
|
||||
<span>Showing all {totalScripts} scripts</span>
|
||||
) : (
|
||||
<span>
|
||||
{filteredCount} of {totalScripts} scripts{" "}
|
||||
{hasActiveFilters && (
|
||||
<span className="font-medium text-info">
|
||||
(filtered)
|
||||
{/* Sort By Dropdown */}
|
||||
<div className="relative w-full sm:w-auto">
|
||||
<Button
|
||||
onClick={() => setIsSortDropdownOpen(!isSortDropdownOpen)}
|
||||
variant="outline"
|
||||
size="default"
|
||||
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" ? (
|
||||
<FileText className="h-4 w-4" />
|
||||
) : (
|
||||
<Calendar className="h-4 w-4" />
|
||||
)}
|
||||
<span>
|
||||
{filters.sortBy === "name" ? "By Name" : "By Created Date"}
|
||||
</span>
|
||||
<svg
|
||||
className={`h-4 w-4 transition-transform ${isSortDropdownOpen ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
|
||||
{isSortDropdownOpen && (
|
||||
<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">
|
||||
<button
|
||||
onClick={() => {
|
||||
updateFilters({ sortBy: "name" });
|
||||
setIsSortDropdownOpen(false);
|
||||
}}
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="text-sm">By Name</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
updateFilters({ sortBy: "created" });
|
||||
setIsSortDropdownOpen(false);
|
||||
}}
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span className="text-sm">By Created Date</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sort Order Button */}
|
||||
<Button
|
||||
onClick={() =>
|
||||
updateFilters({
|
||||
sortOrder: filters.sortOrder === "asc" ? "desc" : "asc",
|
||||
})
|
||||
}
|
||||
variant="outline"
|
||||
size="default"
|
||||
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" ? (
|
||||
<>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 11l5-5m0 0l5 5m-5-5v12"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
{filters.sortBy === "created" ? "Oldest First" : "A-Z"}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 13l-5 5m0 0l-5-5m5 5V6"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
{filters.sortBy === "created" ? "Newest First" : "Z-A"}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filter Summary and Clear All */}
|
||||
<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="text-muted-foreground text-sm">
|
||||
{filteredCount === totalScripts ? (
|
||||
<span>Showing all {totalScripts} scripts</span>
|
||||
) : (
|
||||
<span>
|
||||
{filteredCount} of {totalScripts} scripts{" "}
|
||||
{hasActiveFilters && (
|
||||
<span className="text-info font-medium">(filtered)</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Filter Persistence Status */}
|
||||
{!isLoadingFilters && saveFiltersEnabled && (
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
<span>Filters are being saved automatically</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
onClick={clearAllFilters}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
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
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
<span>Clear all filters</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter Persistence Status */}
|
||||
{!isLoadingFilters && saveFiltersEnabled && (
|
||||
<div className="flex items-center space-x-1 text-xs text-success">
|
||||
<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" />
|
||||
</svg>
|
||||
<span>Filters are being saved automatically</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
onClick={clearAllFilters}
|
||||
variant="ghost"
|
||||
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"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
<span>Clear all filters</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
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
@@ -1,34 +1,45 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { Loader2, CheckCircle, X } from 'lucide-react';
|
||||
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Loader2, CheckCircle, X } from "lucide-react";
|
||||
import { useRegisterModal } from "./modal/ModalStackProvider";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
interface LoadingModalProps {
|
||||
isOpen: boolean;
|
||||
action: string;
|
||||
action?: string;
|
||||
logs?: string[];
|
||||
isComplete?: boolean;
|
||||
title?: string;
|
||||
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
|
||||
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);
|
||||
|
||||
// Auto-scroll to bottom when new logs arrive
|
||||
useEffect(() => {
|
||||
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
logsEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [logs]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<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="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
|
||||
<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 */}
|
||||
{isComplete && onClose && (
|
||||
<Button
|
||||
@@ -40,31 +51,30 @@ export function LoadingModal({ isOpen, action, logs = [], isComplete = false, ti
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="relative">
|
||||
{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" />
|
||||
<div className="absolute inset-0 rounded-full border-2 border-primary/20 animate-pulse"></div>
|
||||
<Loader2 className="text-primary h-12 w-12 animate-spin" />
|
||||
<div className="border-primary/20 absolute inset-0 animate-pulse rounded-full border-2"></div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Static title text */}
|
||||
{title && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{title}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{title && <p className="text-muted-foreground text-sm">{title}</p>}
|
||||
|
||||
{/* Log output */}
|
||||
{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) => (
|
||||
<div key={index} className="mb-1 whitespace-pre-wrap break-words">
|
||||
<div
|
||||
key={index}
|
||||
className="mb-1 break-words whitespace-pre-wrap"
|
||||
>
|
||||
{log}
|
||||
</div>
|
||||
))}
|
||||
@@ -74,9 +84,15 @@ export function LoadingModal({ isOpen, action, logs = [], isComplete = false, ti
|
||||
|
||||
{!isComplete && (
|
||||
<div className="flex space-x-1">
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-bounce"></div>
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
|
||||
<div className="bg-primary h-2 w-2 animate-bounce rounded-full"></div>
|
||||
<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>
|
||||
@@ -84,4 +100,3 @@ export function LoadingModal({ isOpen, action, logs = [], isComplete = false, ti
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Lock, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||
import { api } from '~/trpc/react';
|
||||
import type { Storage } from '~/server/services/storageService';
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { Lock, CheckCircle, AlertCircle } from "lucide-react";
|
||||
import { useRegisterModal } from "./modal/ModalStackProvider";
|
||||
import { api } from "~/trpc/react";
|
||||
import type { Storage } from "~/server/services/storageService";
|
||||
|
||||
interface PBSCredentialsModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -19,25 +19,27 @@ export function PBSCredentialsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
serverId,
|
||||
serverName,
|
||||
storage
|
||||
serverName: _serverName,
|
||||
storage,
|
||||
}: PBSCredentialsModalProps) {
|
||||
const [pbsIp, setPbsIp] = useState('');
|
||||
const [pbsDatastore, setPbsDatastore] = useState('');
|
||||
const [pbsPassword, setPbsPassword] = useState('');
|
||||
const [pbsFingerprint, setPbsFingerprint] = useState('');
|
||||
const [pbsIp, setPbsIp] = useState("");
|
||||
const [pbsDatastore, setPbsDatastore] = useState("");
|
||||
const [pbsPassword, setPbsPassword] = useState("");
|
||||
const [pbsFingerprint, setPbsFingerprint] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
|
||||
// Extract PBS info from storage object
|
||||
const pbsIpFromStorage = (storage as any).server || null;
|
||||
const pbsDatastoreFromStorage = (storage as any).datastore || null;
|
||||
|
||||
const pbsIpFromStorage = (storage as { server?: string }).server ?? null;
|
||||
const pbsDatastoreFromStorage =
|
||||
(storage as { datastore?: string }).datastore ?? null;
|
||||
|
||||
// Fetch existing credentials
|
||||
const { data: credentialData, refetch } = api.pbsCredentials.getCredentialsForStorage.useQuery(
|
||||
{ serverId, storageName: storage.name },
|
||||
{ enabled: isOpen }
|
||||
);
|
||||
|
||||
const { data: credentialData, refetch } =
|
||||
api.pbsCredentials.getCredentialsForStorage.useQuery(
|
||||
{ serverId, storageName: storage.name },
|
||||
{ enabled: isOpen },
|
||||
);
|
||||
|
||||
// Initialize form with storage config values or existing credentials
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
@@ -45,50 +47,54 @@ export function PBSCredentialsModal({
|
||||
// Load existing credentials
|
||||
setPbsIp(credentialData.credential.pbs_ip);
|
||||
setPbsDatastore(credentialData.credential.pbs_datastore);
|
||||
setPbsPassword(''); // Don't show password
|
||||
setPbsFingerprint(credentialData.credential.pbs_fingerprint || '');
|
||||
setPbsPassword(""); // Don't show password
|
||||
setPbsFingerprint(credentialData.credential.pbs_fingerprint ?? "");
|
||||
} else {
|
||||
// Initialize with storage config values
|
||||
setPbsIp(pbsIpFromStorage || '');
|
||||
setPbsDatastore(pbsDatastoreFromStorage || '');
|
||||
setPbsPassword('');
|
||||
setPbsFingerprint('');
|
||||
setPbsIp(pbsIpFromStorage ?? "");
|
||||
setPbsDatastore(pbsDatastoreFromStorage ?? "");
|
||||
setPbsPassword("");
|
||||
setPbsFingerprint("");
|
||||
}
|
||||
}
|
||||
}, [isOpen, credentialData, pbsIpFromStorage, pbsDatastoreFromStorage]);
|
||||
|
||||
|
||||
const saveCredentials = api.pbsCredentials.saveCredentials.useMutation({
|
||||
onSuccess: () => {
|
||||
void refetch();
|
||||
onClose();
|
||||
},
|
||||
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}`);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
const deleteCredentials = api.pbsCredentials.deleteCredentials.useMutation({
|
||||
onSuccess: () => {
|
||||
void refetch();
|
||||
onClose();
|
||||
},
|
||||
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}`);
|
||||
},
|
||||
});
|
||||
|
||||
useRegisterModal(isOpen, { id: 'pbs-credentials-modal', allowEscape: true, onClose });
|
||||
|
||||
|
||||
useRegisterModal(isOpen, {
|
||||
id: "pbs-credentials-modal",
|
||||
allowEscape: true,
|
||||
onClose,
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
// Password is optional when updating existing credentials
|
||||
setIsLoading(true);
|
||||
try {
|
||||
@@ -104,12 +110,16 @@ export function PBSCredentialsModal({
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await deleteCredentials.mutateAsync({
|
||||
@@ -120,19 +130,19 @@ export function PBSCredentialsModal({
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
|
||||
const hasCredentials = credentialData?.success && credentialData.credential;
|
||||
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] flex flex-col border border-border">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
|
||||
<div className="bg-card border-border flex max-h-[90vh] w-full max-w-2xl flex-col rounded-lg border shadow-xl">
|
||||
{/* 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">
|
||||
<Lock className="h-6 w-6 text-primary" />
|
||||
<h2 className="text-2xl font-bold text-card-foreground">
|
||||
<Lock className="text-primary h-6 w-6" />
|
||||
<h2 className="text-card-foreground text-2xl font-bold">
|
||||
PBS Credentials - {storage.name}
|
||||
</h2>
|
||||
</div>
|
||||
@@ -142,18 +152,31 @@ export function PBSCredentialsModal({
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
<svg
|
||||
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>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Storage Name (read-only) */}
|
||||
<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
|
||||
</label>
|
||||
<input
|
||||
@@ -161,13 +184,16 @@ export function PBSCredentialsModal({
|
||||
id="storage-name"
|
||||
value={storage.name}
|
||||
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>
|
||||
|
||||
|
||||
{/* PBS IP */}
|
||||
<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>
|
||||
</label>
|
||||
<input
|
||||
@@ -177,17 +203,20 @@ export function PBSCredentialsModal({
|
||||
onChange={(e) => setPbsIp(e.target.value)}
|
||||
required
|
||||
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"
|
||||
/>
|
||||
<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
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
{/* PBS Datastore */}
|
||||
<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>
|
||||
</label>
|
||||
<input
|
||||
@@ -197,37 +226,48 @@ export function PBSCredentialsModal({
|
||||
onChange={(e) => setPbsDatastore(e.target.value)}
|
||||
required
|
||||
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"
|
||||
/>
|
||||
<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
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
{/* PBS Password */}
|
||||
<div>
|
||||
<label htmlFor="pbs-password" className="block text-sm font-medium text-foreground mb-1">
|
||||
Password {!hasCredentials && <span className="text-error">*</span>}
|
||||
<label
|
||||
htmlFor="pbs-password"
|
||||
className="text-foreground mb-1 block text-sm font-medium"
|
||||
>
|
||||
Password{" "}
|
||||
{!hasCredentials && <span className="text-error">*</span>}
|
||||
</label>
|
||||
<input
|
||||
<input
|
||||
type="password"
|
||||
id="pbs-password"
|
||||
value={pbsPassword}
|
||||
onChange={(e) => setPbsPassword(e.target.value)}
|
||||
required={!hasCredentials}
|
||||
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"
|
||||
placeholder={hasCredentials ? "Enter new password (leave empty to keep existing)" : "Enter PBS password"}
|
||||
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"
|
||||
}
|
||||
/>
|
||||
<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
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
{/* PBS Fingerprint */}
|
||||
<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>
|
||||
</label>
|
||||
<input
|
||||
@@ -237,35 +277,37 @@ export function PBSCredentialsModal({
|
||||
onChange={(e) => setPbsFingerprint(e.target.value)}
|
||||
required
|
||||
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"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Server fingerprint for auto-acceptance. You can find this on your PBS dashboard by clicking the "Show Fingerprint" button.
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Status indicator */}
|
||||
{hasCredentials && (
|
||||
<div className="p-3 bg-success/10 border border-success/20 rounded-lg flex items-center gap-2">
|
||||
<CheckCircle className="h-4 w-4 text-success" />
|
||||
<span className="text-sm text-success font-medium">
|
||||
<div className="bg-success/10 border-success/20 flex items-center gap-2 rounded-lg border p-3">
|
||||
<CheckCircle className="text-success h-4 w-4" />
|
||||
<span className="text-success text-sm font-medium">
|
||||
Credentials are configured for this storage
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* 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 && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
variant="outline"
|
||||
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
|
||||
</Button>
|
||||
)}
|
||||
@@ -274,7 +316,7 @@ export function PBSCredentialsModal({
|
||||
onClick={onClose}
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
className="w-full sm:w-auto order-2"
|
||||
className="order-2 w-full sm:w-auto"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
@@ -282,9 +324,13 @@ export function PBSCredentialsModal({
|
||||
type="submit"
|
||||
variant="default"
|
||||
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>
|
||||
</div>
|
||||
</form>
|
||||
@@ -293,4 +339,3 @@ export function PBSCredentialsModal({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import type { ScriptCard } from '~/types/script';
|
||||
import { TypeBadge, UpdateableBadge } from './Badge';
|
||||
import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
import type { ScriptCard } from "~/types/script";
|
||||
import { TypeBadge, UpdateableBadge } from "./Badge";
|
||||
|
||||
interface ScriptCardProps {
|
||||
script: ScriptCard;
|
||||
@@ -12,7 +12,12 @@ interface ScriptCardProps {
|
||||
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 handleImageError = () => {
|
||||
@@ -27,8 +32,8 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
|
||||
};
|
||||
|
||||
const getRepoName = (url?: string): string => {
|
||||
if (!url) return '';
|
||||
const match = url.match(/github\.com\/([^\/]+)\/([^\/]+)/);
|
||||
if (!url) return "";
|
||||
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(url);
|
||||
if (match) {
|
||||
return `${match[1]}/${match[2]}`;
|
||||
}
|
||||
@@ -37,32 +42,36 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-card rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer border border-border hover:border-primary h-full flex flex-col 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)}
|
||||
>
|
||||
{/* Checkbox in top-left corner */}
|
||||
{onToggleSelect && (
|
||||
<div className="absolute top-2 left-2 z-10">
|
||||
<div
|
||||
className={`w-4 h-4 border-2 rounded cursor-pointer transition-all duration-200 flex items-center justify-center ${
|
||||
isSelected
|
||||
? 'bg-primary border-primary text-primary-foreground'
|
||||
: 'bg-card border-border hover:border-primary/60 hover:bg-accent'
|
||||
<div
|
||||
className={`flex h-4 w-4 cursor-pointer items-center justify-center rounded border-2 transition-all duration-200 ${
|
||||
isSelected
|
||||
? "bg-primary border-primary text-primary-foreground"
|
||||
: "bg-card border-border hover:border-primary/60 hover:bg-accent"
|
||||
}`}
|
||||
onClick={handleCheckboxClick}
|
||||
>
|
||||
{isSelected && (
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
<svg 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>
|
||||
)}
|
||||
</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 */}
|
||||
<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">
|
||||
{script.logo && !imageError ? (
|
||||
<Image
|
||||
@@ -70,42 +79,49 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
|
||||
alt={`${script.name} logo`}
|
||||
width={48}
|
||||
height={48}
|
||||
className="w-12 h-12 rounded-lg object-contain"
|
||||
className="h-12 w-12 rounded-lg object-contain"
|
||||
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">
|
||||
{script.name?.charAt(0)?.toUpperCase() || '?'}
|
||||
{script.name?.charAt(0)?.toUpperCase() || "?"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-semibold text-foreground truncate">
|
||||
{script.name || 'Unnamed Script'}
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-foreground truncate text-lg font-semibold">
|
||||
{script.name || "Unnamed Script"}
|
||||
</h3>
|
||||
<div className="mt-2 space-y-2">
|
||||
{/* Type and Updateable status on first row */}
|
||||
<div className="flex items-center space-x-2 flex-wrap gap-1">
|
||||
<TypeBadge type={script.type ?? 'unknown'} />
|
||||
<div className="flex flex-wrap items-center gap-1 space-x-2">
|
||||
<TypeBadge type={script.type ?? "unknown"} />
|
||||
{script.updateable && <UpdateableBadge />}
|
||||
{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)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Download Status */}
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
script.isDownloaded ? 'bg-success' : 'bg-error'
|
||||
}`}></div>
|
||||
<span className={`text-xs font-medium ${
|
||||
script.isDownloaded ? 'text-success' : 'text-error'
|
||||
}`}>
|
||||
{script.isDownloaded ? 'Downloaded' : 'Not Downloaded'}
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${
|
||||
script.isDownloaded ? "bg-success" : "bg-error"
|
||||
}`}
|
||||
></div>
|
||||
<span
|
||||
className={`text-xs font-medium ${
|
||||
script.isDownloaded ? "text-success" : "text-error"
|
||||
}`}
|
||||
>
|
||||
{script.isDownloaded ? "Downloaded" : "Not Downloaded"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,8 +129,8 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-muted-foreground text-sm line-clamp-3 mb-4 flex-1">
|
||||
{script.description || 'No description available'}
|
||||
<p className="text-muted-foreground mb-4 line-clamp-3 flex-1 text-sm">
|
||||
{script.description || "No description available"}
|
||||
</p>
|
||||
|
||||
{/* Footer with website link */}
|
||||
@@ -124,12 +140,22 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
|
||||
href={script.website}
|
||||
target="_blank"
|
||||
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()}
|
||||
>
|
||||
<span>Website</span>
|
||||
<svg className="w-3 h-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
|
||||
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>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import type { ScriptCard } from '~/types/script';
|
||||
import { TypeBadge, UpdateableBadge } from './Badge';
|
||||
import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
import type { ScriptCard } from "~/types/script";
|
||||
import { TypeBadge, UpdateableBadge } from "./Badge";
|
||||
|
||||
interface ScriptCardListProps {
|
||||
script: ScriptCard;
|
||||
@@ -12,7 +12,12 @@ interface ScriptCardListProps {
|
||||
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 handleImageError = () => {
|
||||
@@ -27,26 +32,27 @@ export function ScriptCardList({ script, onClick, isSelected = false, onToggleSe
|
||||
};
|
||||
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return 'Unknown';
|
||||
if (!dateString) return "Unknown";
|
||||
try {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
return new Date(dateString).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return 'Unknown';
|
||||
return "Unknown";
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryNames = () => {
|
||||
if (!script.categoryNames || script.categoryNames.length === 0) return 'Uncategorized';
|
||||
return script.categoryNames.join(', ');
|
||||
if (!script.categoryNames || script.categoryNames.length === 0)
|
||||
return "Uncategorized";
|
||||
return script.categoryNames.join(", ");
|
||||
};
|
||||
|
||||
const getRepoName = (url?: string): string => {
|
||||
if (!url) return '';
|
||||
const match = url.match(/github\.com\/([^\/]+)\/([^\/]+)/);
|
||||
if (!url) return "";
|
||||
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(url);
|
||||
if (match) {
|
||||
return `${match[1]}/${match[2]}`;
|
||||
}
|
||||
@@ -55,30 +61,34 @@ export function ScriptCardList({ script, onClick, isSelected = false, onToggleSe
|
||||
|
||||
return (
|
||||
<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)}
|
||||
>
|
||||
{/* Checkbox */}
|
||||
{onToggleSelect && (
|
||||
<div className="absolute top-4 left-4 z-10">
|
||||
<div
|
||||
className={`w-4 h-4 border-2 rounded cursor-pointer transition-all duration-200 flex items-center justify-center ${
|
||||
isSelected
|
||||
? 'bg-primary border-primary text-primary-foreground'
|
||||
: 'bg-card border-border hover:border-primary/60 hover:bg-accent'
|
||||
<div
|
||||
className={`flex h-4 w-4 cursor-pointer items-center justify-center rounded border-2 transition-all duration-200 ${
|
||||
isSelected
|
||||
? "bg-primary border-primary text-primary-foreground"
|
||||
: "bg-card border-border hover:border-primary/60 hover:bg-accent"
|
||||
}`}
|
||||
onClick={handleCheckboxClick}
|
||||
>
|
||||
{isSelected && (
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
<svg 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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`p-6 ${onToggleSelect ? 'pl-12' : ''}`}>
|
||||
|
||||
<div className={`p-6 ${onToggleSelect ? "pl-12" : ""}`}>
|
||||
<div className="flex items-start space-x-4">
|
||||
{/* Logo */}
|
||||
<div className="flex-shrink-0">
|
||||
@@ -88,42 +98,49 @@ export function ScriptCardList({ script, onClick, isSelected = false, onToggleSe
|
||||
alt={`${script.name} logo`}
|
||||
width={56}
|
||||
height={56}
|
||||
className="w-14 h-14 rounded-lg object-contain"
|
||||
className="h-14 w-14 rounded-lg object-contain"
|
||||
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">
|
||||
{script.name?.charAt(0)?.toUpperCase() || '?'}
|
||||
{script.name?.charAt(0)?.toUpperCase() || "?"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* Header Row */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-xl font-semibold text-foreground truncate mb-2">
|
||||
{script.name || 'Unnamed Script'}
|
||||
<div className="mb-3 flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-foreground mb-2 truncate text-xl font-semibold">
|
||||
{script.name || "Unnamed Script"}
|
||||
</h3>
|
||||
<div className="flex items-center space-x-3 flex-wrap gap-2">
|
||||
<TypeBadge type={script.type ?? 'unknown'} />
|
||||
<div className="flex flex-wrap items-center gap-2 space-x-3">
|
||||
<TypeBadge type={script.type ?? "unknown"} />
|
||||
{script.updateable && <UpdateableBadge />}
|
||||
{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)}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
script.isDownloaded ? 'bg-success' : 'bg-error'
|
||||
}`}></div>
|
||||
<span className={`text-sm font-medium ${
|
||||
script.isDownloaded ? 'text-success' : 'text-error'
|
||||
}`}>
|
||||
{script.isDownloaded ? 'Downloaded' : 'Not Downloaded'}
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${
|
||||
script.isDownloaded ? "bg-success" : "bg-error"
|
||||
}`}
|
||||
></div>
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
script.isDownloaded ? "text-success" : "text-error"
|
||||
}`}
|
||||
>
|
||||
{script.isDownloaded ? "Downloaded" : "Not Downloaded"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -135,68 +152,128 @@ export function ScriptCardList({ script, onClick, isSelected = false, onToggleSe
|
||||
href={script.website}
|
||||
target="_blank"
|
||||
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()}
|
||||
>
|
||||
<span>Website</span>
|
||||
<svg className="w-4 h-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
|
||||
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>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-muted-foreground text-sm mb-4 line-clamp-2">
|
||||
{script.description || 'No description available'}
|
||||
<p className="text-muted-foreground mb-4 line-clamp-2 text-sm">
|
||||
{script.description || "No description available"}
|
||||
</p>
|
||||
|
||||
{/* 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-1">
|
||||
<svg className="w-3 h-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
|
||||
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>
|
||||
<span>Categories: {getCategoryNames()}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<svg className="w-3 h-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
|
||||
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>
|
||||
<span>Created: {formatDate(script.date_created)}</span>
|
||||
</div>
|
||||
{(script.os ?? script.version) && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<svg className="w-3 h-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
|
||||
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>
|
||||
<span>
|
||||
{script.os && script.version
|
||||
{script.os && script.version
|
||||
? `${script.os.charAt(0).toUpperCase() + script.os.slice(1)} ${script.version}`
|
||||
: script.os
|
||||
? script.os.charAt(0).toUpperCase() + script.os.slice(1)
|
||||
: script.os
|
||||
? script.os.charAt(0).toUpperCase() +
|
||||
script.os.slice(1)
|
||||
: script.version
|
||||
? `Version ${script.version}`
|
||||
: ''
|
||||
}
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{script.interface_port && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<svg className="w-3 h-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
|
||||
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>
|
||||
<span>Port: {script.interface_port}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<svg className="w-3 h-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
|
||||
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>
|
||||
<span>ID: {script.slug || 'unknown'}</span>
|
||||
<span>ID: {script.slug || "unknown"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,14 +4,20 @@ import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { api } from "~/trpc/react";
|
||||
import type { Script } from "~/types/script";
|
||||
import type { Server } from "~/types/server";
|
||||
import { DiffViewer } from "./DiffViewer";
|
||||
import { TextViewer } from "./TextViewer";
|
||||
import { ExecutionModeModal } from "./ExecutionModeModal";
|
||||
import { ConfirmationModal } from "./ConfirmationModal";
|
||||
import { ScriptVersionModal } from "./ScriptVersionModal";
|
||||
import { TypeBadge, UpdateableBadge, PrivilegedBadge, NoteBadge } from "./Badge";
|
||||
import {
|
||||
TypeBadge,
|
||||
UpdateableBadge,
|
||||
PrivilegedBadge,
|
||||
NoteBadge,
|
||||
} from "./Badge";
|
||||
import { Button } from "./ui/button";
|
||||
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||
import { useRegisterModal } from "./modal/ModalStackProvider";
|
||||
|
||||
interface ScriptDetailModalProps {
|
||||
script: Script | null;
|
||||
@@ -21,7 +27,7 @@ interface ScriptDetailModalProps {
|
||||
scriptPath: string,
|
||||
scriptName: string,
|
||||
mode?: "local" | "ssh",
|
||||
server?: any,
|
||||
server?: Server,
|
||||
) => void;
|
||||
}
|
||||
|
||||
@@ -31,7 +37,11 @@ export function ScriptDetailModal({
|
||||
onClose,
|
||||
onInstallScript,
|
||||
}: 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 [isLoading, setIsLoading] = useState(false);
|
||||
const [loadMessage, setLoadMessage] = useState<string | null>(null);
|
||||
@@ -40,7 +50,9 @@ export function ScriptDetailModal({
|
||||
const [textViewerOpen, setTextViewerOpen] = useState(false);
|
||||
const [executionModeOpen, setExecutionModeOpen] = 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 [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
|
||||
@@ -61,7 +73,7 @@ export function ScriptDetailModal({
|
||||
isLoading: comparisonLoading,
|
||||
} = api.scripts.compareScriptContent.useQuery(
|
||||
{ slug: script?.slug ?? "" },
|
||||
{
|
||||
{
|
||||
enabled: !!script && isOpen,
|
||||
refetchOnMount: true,
|
||||
staleTime: 0,
|
||||
@@ -140,22 +152,27 @@ export function ScriptDetailModal({
|
||||
|
||||
const handleInstallScript = () => {
|
||||
if (!script) return;
|
||||
|
||||
|
||||
// Check if script has multiple variants (default and alpine)
|
||||
const installMethods = script.install_methods || [];
|
||||
const hasMultipleVariants = installMethods.filter(method =>
|
||||
method.type === 'default' || method.type === 'alpine'
|
||||
).length > 1;
|
||||
|
||||
const hasMultipleVariants =
|
||||
installMethods.filter(
|
||||
(method) => method.type === "default" || method.type === "alpine",
|
||||
).length > 1;
|
||||
|
||||
if (hasMultipleVariants) {
|
||||
// Show version selection modal first
|
||||
setVersionModalOpen(true);
|
||||
} else {
|
||||
// Only one variant, proceed directly to execution mode
|
||||
// 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];
|
||||
setSelectedVersionType(defaultMethod?.type || firstMethod?.type || 'default');
|
||||
setSelectedVersionType(
|
||||
defaultMethod?.type ?? firstMethod?.type ?? "default",
|
||||
);
|
||||
setExecutionModeOpen(true);
|
||||
}
|
||||
};
|
||||
@@ -166,17 +183,16 @@ export function ScriptDetailModal({
|
||||
setExecutionModeOpen(true);
|
||||
};
|
||||
|
||||
const handleExecuteScript = (mode: "local" | "ssh", server?: any) => {
|
||||
const handleExecuteScript = (mode: "local" | "ssh", server?: Server) => {
|
||||
if (!script || !onInstallScript) return;
|
||||
|
||||
// Find the script path based on selected version type
|
||||
const versionType = selectedVersionType || 'default';
|
||||
const scriptMethod = script.install_methods?.find(
|
||||
(method) => method.type === versionType && method.script,
|
||||
) || script.install_methods?.find(
|
||||
(method) => method.script,
|
||||
);
|
||||
|
||||
const versionType = selectedVersionType ?? "default";
|
||||
const scriptMethod =
|
||||
script.install_methods?.find(
|
||||
(method) => method.type === versionType && method.script,
|
||||
) ?? script.install_methods?.find((method) => method.script);
|
||||
|
||||
if (scriptMethod?.script) {
|
||||
const scriptPath = `scripts/${scriptMethod.script}`;
|
||||
const scriptName = script.name;
|
||||
@@ -207,31 +223,31 @@ export function ScriptDetailModal({
|
||||
|
||||
return (
|
||||
<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}
|
||||
>
|
||||
<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 */}
|
||||
<div className="flex items-center justify-between border-b border-border p-4 sm:p-6">
|
||||
<div className="flex items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
|
||||
<div className="border-border flex items-center justify-between border-b p-4 sm:p-6">
|
||||
<div className="flex min-w-0 flex-1 items-center space-x-3 sm:space-x-4">
|
||||
{script.logo && !imageError ? (
|
||||
<Image
|
||||
src={script.logo}
|
||||
alt={`${script.name} logo`}
|
||||
width={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}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-12 w-12 sm:h-16 sm:w-16 items-center justify-center rounded-lg bg-muted flex-shrink-0">
|
||||
<span className="text-lg sm:text-2xl font-semibold text-muted-foreground">
|
||||
<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-muted-foreground text-lg font-semibold sm:text-2xl">
|
||||
{script.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<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}
|
||||
</h2>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1 sm:gap-2">
|
||||
@@ -243,37 +259,39 @@ export function ScriptDetailModal({
|
||||
href={script.repository_url}
|
||||
target="_blank"
|
||||
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()}
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Interface Port*/}
|
||||
{script.interface_port && (
|
||||
<div className="ml-3 sm:ml-4 flex-shrink-0">
|
||||
<div className="bg-primary/10 border border-primary/30 rounded-lg 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">
|
||||
<div className="ml-3 flex-shrink-0 sm:ml-4">
|
||||
<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-muted-foreground mr-2 text-xs font-medium sm:text-sm">
|
||||
Port:
|
||||
</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}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Close Button */}
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="ghost"
|
||||
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
|
||||
className="h-5 w-5 sm:h-6 sm:w-6"
|
||||
@@ -292,189 +310,91 @@ export function ScriptDetailModal({
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
{/* Install Button - only show if script files exist */}
|
||||
{scriptFilesData?.success &&
|
||||
scriptFilesData.ctExists &&
|
||||
onInstallScript && (
|
||||
<Button
|
||||
onClick={handleInstallScript}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="w-full sm:w-auto flex items-center justify-center space-x-2"
|
||||
<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 */}
|
||||
{scriptFilesData?.success &&
|
||||
scriptFilesData.ctExists &&
|
||||
onInstallScript && (
|
||||
<Button
|
||||
onClick={handleInstallScript}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="flex w-full items-center justify-center space-x-2 sm:w-auto"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
|
||||
/>
|
||||
</svg>
|
||||
<span>Install</span>
|
||||
</Button>
|
||||
)}
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
|
||||
/>
|
||||
</svg>
|
||||
<span>Install</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* View Button - only show if script files exist */}
|
||||
{scriptFilesData?.success &&
|
||||
(scriptFilesData.ctExists || scriptFilesData.installExists) && (
|
||||
<Button
|
||||
onClick={handleViewScript}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="w-full sm:w-auto flex items-center justify-center space-x-2"
|
||||
{/* View Button - only show if script files exist */}
|
||||
{scriptFilesData?.success &&
|
||||
(scriptFilesData.ctExists || scriptFilesData.installExists) && (
|
||||
<Button
|
||||
onClick={handleViewScript}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="flex w-full items-center justify-center space-x-2 sm:w-auto"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
<span>View</span>
|
||||
</Button>
|
||||
)}
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
<span>View</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Load/Update Script Button */}
|
||||
{(() => {
|
||||
const hasLocalFiles =
|
||||
scriptFilesData?.success &&
|
||||
(scriptFilesData.ctExists || scriptFilesData.installExists);
|
||||
const hasDifferences =
|
||||
comparisonData?.success && comparisonData.hasDifferences;
|
||||
const isUpToDate = hasLocalFiles && !hasDifferences;
|
||||
{/* Load/Update Script Button */}
|
||||
{(() => {
|
||||
const hasLocalFiles =
|
||||
scriptFilesData?.success &&
|
||||
(scriptFilesData.ctExists || scriptFilesData.installExists);
|
||||
const hasDifferences =
|
||||
comparisonData?.success && comparisonData.hasDifferences;
|
||||
const isUpToDate = hasLocalFiles && !hasDifferences;
|
||||
|
||||
if (!hasLocalFiles) {
|
||||
// No local files - show Load Script button
|
||||
return (
|
||||
<button
|
||||
onClick={handleLoadScript}
|
||||
disabled={isLoading}
|
||||
className={`flex items-center space-x-2 rounded-lg px-4 py-2 font-medium transition-colors ${
|
||||
isLoading
|
||||
? "cursor-not-allowed bg-muted text-muted-foreground"
|
||||
: "bg-success text-success-foreground hover:bg-success/90"
|
||||
}`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-white"></div>
|
||||
<span>Loading...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
|
||||
<span>Load Script</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
} else if (isUpToDate) {
|
||||
// Local files exist and are up to date - show disabled Update button
|
||||
return (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
<span>Up to Date</span>
|
||||
</button>
|
||||
);
|
||||
} else {
|
||||
// Local files exist but have differences - show Update button
|
||||
return (
|
||||
<button
|
||||
onClick={handleLoadScript}
|
||||
disabled={isLoading}
|
||||
className={`flex items-center space-x-2 rounded-lg px-4 py-2 font-medium transition-colors ${
|
||||
isLoading
|
||||
? "cursor-not-allowed bg-muted text-muted-foreground"
|
||||
: "bg-warning text-warning-foreground hover:bg-warning/90"
|
||||
}`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-white"></div>
|
||||
<span>Updating...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
<span>Update Script</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
|
||||
{/* Delete Button - only show if script files exist */}
|
||||
{scriptFilesData?.success &&
|
||||
(scriptFilesData.ctExists || scriptFilesData.installExists) && (
|
||||
<Button
|
||||
onClick={handleDeleteScript}
|
||||
disabled={isDeleting}
|
||||
variant="destructive"
|
||||
size="default"
|
||||
className="w-full sm:w-auto flex items-center justify-center space-x-2"
|
||||
if (!hasLocalFiles) {
|
||||
// No local files - show Load Script button
|
||||
return (
|
||||
<button
|
||||
onClick={handleLoadScript}
|
||||
disabled={isLoading}
|
||||
className={`flex items-center space-x-2 rounded-lg px-4 py-2 font-medium transition-colors ${
|
||||
isLoading
|
||||
? "bg-muted text-muted-foreground cursor-not-allowed"
|
||||
: "bg-success text-success-foreground hover:bg-success/90"
|
||||
}`}
|
||||
>
|
||||
{isDeleting ? (
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-white"></div>
|
||||
<span>Deleting...</span>
|
||||
<span>Loading...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -488,23 +408,121 @@ export function ScriptDetailModal({
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
|
||||
<span>Delete Script</span>
|
||||
<span>Load Script</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
} else if (isUpToDate) {
|
||||
// Local files exist and are up to date - show disabled Update button
|
||||
return (
|
||||
<button
|
||||
disabled
|
||||
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
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
<span>Up to Date</span>
|
||||
</button>
|
||||
);
|
||||
} else {
|
||||
// Local files exist but have differences - show Update button
|
||||
return (
|
||||
<button
|
||||
onClick={handleLoadScript}
|
||||
disabled={isLoading}
|
||||
className={`flex items-center space-x-2 rounded-lg px-4 py-2 font-medium transition-colors ${
|
||||
isLoading
|
||||
? "bg-muted text-muted-foreground cursor-not-allowed"
|
||||
: "bg-warning text-warning-foreground hover:bg-warning/90"
|
||||
}`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-white"></div>
|
||||
<span>Updating...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
<span>Update Script</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
|
||||
{/* Delete Button - only show if script files exist */}
|
||||
{scriptFilesData?.success &&
|
||||
(scriptFilesData.ctExists || scriptFilesData.installExists) && (
|
||||
<Button
|
||||
onClick={handleDeleteScript}
|
||||
disabled={isDeleting}
|
||||
variant="destructive"
|
||||
size="default"
|
||||
className="flex w-full items-center justify-center space-x-2 sm:w-auto"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-white"></div>
|
||||
<span>Deleting...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
<span>Delete Script</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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 */}
|
||||
{(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="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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -527,8 +545,8 @@ export function ScriptDetailModal({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-4 rounded-lg bg-muted p-3 text-sm text-muted-foreground">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center space-y-2 sm:space-y-0 sm:space-x-4">
|
||||
<div className="bg-muted text-muted-foreground mb-4 rounded-lg p-3 text-sm">
|
||||
<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={`h-2 w-2 rounded-full ${scriptFilesData.ctExists ? "bg-success" : "bg-muted"}`}
|
||||
@@ -567,31 +585,33 @@ export function ScriptDetailModal({
|
||||
</>
|
||||
) : 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>
|
||||
</>
|
||||
) : comparisonData?.error ? (
|
||||
<>
|
||||
<div className="h-2 w-2 rounded-full bg-destructive"></div>
|
||||
<span className="text-destructive">Error: {comparisonData.error}</span>
|
||||
<div className="bg-destructive h-2 w-2 rounded-full"></div>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => void refetchComparison()}
|
||||
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"
|
||||
>
|
||||
{comparisonLoading ? (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></div>
|
||||
) : (
|
||||
<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"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -609,7 +629,7 @@ export function ScriptDetailModal({
|
||||
)}
|
||||
</div>
|
||||
{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(", ")}
|
||||
</div>
|
||||
)}
|
||||
@@ -619,17 +639,17 @@ export function ScriptDetailModal({
|
||||
|
||||
{/* Load Message */}
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<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
|
||||
</h3>
|
||||
<p className="text-sm sm:text-base text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm sm:text-base">
|
||||
{script.description}
|
||||
</p>
|
||||
</div>
|
||||
@@ -637,50 +657,50 @@ export function ScriptDetailModal({
|
||||
{/* Basic Information */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:gap-6 lg:grid-cols-2">
|
||||
<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
|
||||
</h3>
|
||||
<dl className="space-y-2">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
<dt className="text-muted-foreground text-sm font-medium">
|
||||
Slug
|
||||
</dt>
|
||||
<dd className="font-mono text-sm text-foreground">
|
||||
<dd className="text-foreground font-mono text-sm">
|
||||
{script.slug}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
<dt className="text-muted-foreground text-sm font-medium">
|
||||
Date Created
|
||||
</dt>
|
||||
<dd className="text-sm text-foreground">
|
||||
<dd className="text-foreground text-sm">
|
||||
{script.date_created}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
<dt className="text-muted-foreground text-sm font-medium">
|
||||
Categories
|
||||
</dt>
|
||||
<dd className="text-sm text-foreground">
|
||||
<dd className="text-foreground text-sm">
|
||||
{script.categories.join(", ")}
|
||||
</dd>
|
||||
</div>
|
||||
{script.interface_port && (
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
<dt className="text-muted-foreground text-sm font-medium">
|
||||
Interface Port
|
||||
</dt>
|
||||
<dd className="text-sm text-foreground">
|
||||
<dd className="text-foreground text-sm">
|
||||
{script.interface_port}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{script.config_path && (
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
<dt className="text-muted-foreground text-sm font-medium">
|
||||
Config Path
|
||||
</dt>
|
||||
<dd className="font-mono text-sm text-foreground">
|
||||
<dd className="text-foreground font-mono text-sm">
|
||||
{script.config_path}
|
||||
</dd>
|
||||
</div>
|
||||
@@ -689,13 +709,13 @@ export function ScriptDetailModal({
|
||||
</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
|
||||
</h3>
|
||||
<dl className="space-y-2">
|
||||
{script.website && (
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
<dt className="text-muted-foreground text-sm font-medium">
|
||||
Website
|
||||
</dt>
|
||||
<dd className="text-sm">
|
||||
@@ -703,7 +723,7 @@ export function ScriptDetailModal({
|
||||
href={script.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="break-all text-primary hover:text-primary/80"
|
||||
className="text-primary hover:text-primary/80 break-all"
|
||||
>
|
||||
{script.website}
|
||||
</a>
|
||||
@@ -712,7 +732,7 @@ export function ScriptDetailModal({
|
||||
)}
|
||||
{script.documentation && (
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
<dt className="text-muted-foreground text-sm font-medium">
|
||||
Documentation
|
||||
</dt>
|
||||
<dd className="text-sm">
|
||||
@@ -720,7 +740,7 @@ export function ScriptDetailModal({
|
||||
href={script.documentation}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="break-all text-primary hover:text-primary/80"
|
||||
className="text-primary hover:text-primary/80 break-all"
|
||||
>
|
||||
{script.documentation}
|
||||
</a>
|
||||
@@ -736,26 +756,26 @@ export function ScriptDetailModal({
|
||||
script.type !== "pve" &&
|
||||
script.type !== "addon" && (
|
||||
<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
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{script.install_methods.map((method, index) => (
|
||||
<div
|
||||
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">
|
||||
<h4 className="text-sm sm:text-base font-medium text-foreground capitalize">
|
||||
<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-foreground text-sm font-medium capitalize sm:text-base">
|
||||
{method.type}
|
||||
</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}
|
||||
</span>
|
||||
</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>
|
||||
<dt className="font-medium text-muted-foreground">
|
||||
<dt className="text-muted-foreground font-medium">
|
||||
CPU
|
||||
</dt>
|
||||
<dd className="text-foreground">
|
||||
@@ -763,7 +783,7 @@ export function ScriptDetailModal({
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-medium text-muted-foreground">
|
||||
<dt className="text-muted-foreground font-medium">
|
||||
RAM
|
||||
</dt>
|
||||
<dd className="text-foreground">
|
||||
@@ -771,7 +791,7 @@ export function ScriptDetailModal({
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-medium text-muted-foreground">
|
||||
<dt className="text-muted-foreground font-medium">
|
||||
HDD
|
||||
</dt>
|
||||
<dd className="text-foreground">
|
||||
@@ -779,7 +799,7 @@ export function ScriptDetailModal({
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-medium text-muted-foreground">
|
||||
<dt className="text-muted-foreground font-medium">
|
||||
OS
|
||||
</dt>
|
||||
<dd className="text-foreground">
|
||||
@@ -797,26 +817,26 @@ export function ScriptDetailModal({
|
||||
{(script.default_credentials.username ??
|
||||
script.default_credentials.password) && (
|
||||
<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
|
||||
</h3>
|
||||
<dl className="space-y-2">
|
||||
{script.default_credentials.username && (
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
<dt className="text-muted-foreground text-sm font-medium">
|
||||
Username
|
||||
</dt>
|
||||
<dd className="font-mono text-sm text-foreground">
|
||||
<dd className="text-foreground font-mono text-sm">
|
||||
{script.default_credentials.username}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{script.default_credentials.password && (
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
<dt className="text-muted-foreground text-sm font-medium">
|
||||
Password
|
||||
</dt>
|
||||
<dd className="font-mono text-sm text-foreground">
|
||||
<dd className="text-foreground font-mono text-sm">
|
||||
{script.default_credentials.password}
|
||||
</dd>
|
||||
</div>
|
||||
@@ -828,7 +848,7 @@ export function ScriptDetailModal({
|
||||
{/* Notes */}
|
||||
{script.notes.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-3 text-lg font-semibold text-foreground">
|
||||
<h3 className="text-foreground mb-3 text-lg font-semibold">
|
||||
Notes
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
@@ -843,14 +863,17 @@ export function ScriptDetailModal({
|
||||
key={index}
|
||||
className={`rounded-lg p-3 text-sm ${
|
||||
noteType === "warning"
|
||||
? "border-l-4 border-warning bg-warning/10 text-warning"
|
||||
? "border-warning bg-warning/10 text-warning border-l-4"
|
||||
: 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"
|
||||
}`}
|
||||
>
|
||||
<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}
|
||||
</NoteBadge>
|
||||
<span>{noteText}</span>
|
||||
@@ -882,7 +905,13 @@ export function ScriptDetailModal({
|
||||
<TextViewer
|
||||
scriptName={
|
||||
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("/")
|
||||
.pop() ?? `${script.slug}.sh`
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { Script, ScriptInstallMethod } from '../../types/script';
|
||||
import { Button } from './ui/button';
|
||||
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||
import { useState } from "react";
|
||||
import type { Script } from "../../types/script";
|
||||
import { Button } from "./ui/button";
|
||||
import { useRegisterModal } from "./modal/ModalStackProvider";
|
||||
|
||||
interface ScriptVersionModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -12,16 +12,29 @@ interface ScriptVersionModalProps {
|
||||
script: Script | null;
|
||||
}
|
||||
|
||||
export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }: ScriptVersionModalProps) {
|
||||
useRegisterModal(isOpen, { id: 'script-version-modal', allowEscape: true, onClose });
|
||||
export function ScriptVersionModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSelectVersion,
|
||||
script,
|
||||
}: ScriptVersionModalProps) {
|
||||
useRegisterModal(isOpen, {
|
||||
id: "script-version-modal",
|
||||
allowEscape: true,
|
||||
onClose,
|
||||
});
|
||||
const [selectedVersion, setSelectedVersion] = useState<string | null>(null);
|
||||
|
||||
if (!isOpen || !script) return null;
|
||||
|
||||
// Get available install methods
|
||||
const installMethods = script.install_methods || [];
|
||||
const defaultMethod = installMethods.find(method => method.type === 'default');
|
||||
const alpineMethod = installMethods.find(method => method.type === 'alpine');
|
||||
const defaultMethod = installMethods.find(
|
||||
(method) => method.type === "default",
|
||||
);
|
||||
const alpineMethod = installMethods.find(
|
||||
(method) => method.type === "alpine",
|
||||
);
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (selectedVersion) {
|
||||
@@ -35,19 +48,29 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-2xl w-full border border-border">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
|
||||
<div className="bg-card border-border w-full max-w-2xl rounded-lg border shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||
<h2 className="text-xl font-bold text-foreground">Select Version</h2>
|
||||
<div className="border-border flex items-center justify-between border-b p-6">
|
||||
<h2 className="text-foreground text-xl font-bold">Select Version</h2>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
<svg
|
||||
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>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -55,11 +78,12 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
|
||||
{/* Content */}
|
||||
<div className="p-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 "{script.name}"
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select the version you want to install. Each version has different resource requirements.
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Select the version you want to install. Each version has different
|
||||
resource requirements.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -67,25 +91,29 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
|
||||
{/* Default Version */}
|
||||
{defaultMethod && (
|
||||
<div
|
||||
onClick={() => handleVersionSelect('default')}
|
||||
onClick={() => handleVersionSelect("default")}
|
||||
className={`cursor-pointer rounded-lg border-2 p-4 transition-all ${
|
||||
selectedVersion === 'default'
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border bg-card hover:border-primary/50'
|
||||
selectedVersion === "default"
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-border bg-card hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<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
|
||||
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
|
||||
selectedVersion === 'default'
|
||||
? 'border-primary bg-primary'
|
||||
: 'border-border'
|
||||
className={`flex h-5 w-5 items-center justify-center rounded-full border-2 ${
|
||||
selectedVersion === "default"
|
||||
? "border-primary bg-primary"
|
||||
: "border-border"
|
||||
}`}
|
||||
>
|
||||
{selectedVersion === 'default' && (
|
||||
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
{selectedVersion === "default" && (
|
||||
<svg
|
||||
className="h-3 w-3 text-white"
|
||||
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"
|
||||
@@ -94,27 +122,34 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<h4 className="text-base font-semibold text-foreground capitalize">
|
||||
<h4 className="text-foreground text-base font-semibold capitalize">
|
||||
{defaultMethod.type}
|
||||
</h4>
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<span className="text-muted-foreground">OS: </span>
|
||||
<span className="text-foreground font-medium">
|
||||
{defaultMethod.resources.os} {defaultMethod.resources.version}
|
||||
{defaultMethod.resources.os}{" "}
|
||||
{defaultMethod.resources.version}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -126,25 +161,29 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
|
||||
{/* Alpine Version */}
|
||||
{alpineMethod && (
|
||||
<div
|
||||
onClick={() => handleVersionSelect('alpine')}
|
||||
onClick={() => handleVersionSelect("alpine")}
|
||||
className={`cursor-pointer rounded-lg border-2 p-4 transition-all ${
|
||||
selectedVersion === 'alpine'
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border bg-card hover:border-primary/50'
|
||||
selectedVersion === "alpine"
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-border bg-card hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<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
|
||||
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
|
||||
selectedVersion === 'alpine'
|
||||
? 'border-primary bg-primary'
|
||||
: 'border-border'
|
||||
className={`flex h-5 w-5 items-center justify-center rounded-full border-2 ${
|
||||
selectedVersion === "alpine"
|
||||
? "border-primary bg-primary"
|
||||
: "border-border"
|
||||
}`}
|
||||
>
|
||||
{selectedVersion === 'alpine' && (
|
||||
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
{selectedVersion === "alpine" && (
|
||||
<svg
|
||||
className="h-3 w-3 text-white"
|
||||
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"
|
||||
@@ -153,27 +192,34 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<h4 className="text-base font-semibold text-foreground capitalize">
|
||||
<h4 className="text-foreground text-base font-semibold capitalize">
|
||||
{alpineMethod.type}
|
||||
</h4>
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<span className="text-muted-foreground">OS: </span>
|
||||
<span className="text-foreground font-medium">
|
||||
{alpineMethod.resources.os} {alpineMethod.resources.version}
|
||||
{alpineMethod.resources.os}{" "}
|
||||
{alpineMethod.resources.version}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -184,12 +230,8 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end space-x-3 mt-6">
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="outline"
|
||||
size="default"
|
||||
>
|
||||
<div className="mt-6 flex justify-end space-x-3">
|
||||
<Button onClick={onClose} variant="outline" size="default">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
@@ -197,7 +239,9 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
|
||||
disabled={!selectedVersion}
|
||||
variant="default"
|
||||
size="default"
|
||||
className={!selectedVersion ? 'bg-muted-foreground cursor-not-allowed' : ''}
|
||||
className={
|
||||
!selectedVersion ? "bg-muted-foreground cursor-not-allowed" : ""
|
||||
}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
@@ -207,4 +251,3 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { CreateServerData } from '../../types/server';
|
||||
import { Button } from './ui/button';
|
||||
import { SSHKeyInput } from './SSHKeyInput';
|
||||
import { PublicKeyModal } from './PublicKeyModal';
|
||||
import { Key } from 'lucide-react';
|
||||
import { useState, useEffect } from "react";
|
||||
import type { CreateServerData } from "../../types/server";
|
||||
import { Button } from "./ui/button";
|
||||
import { SSHKeyInput } from "./SSHKeyInput";
|
||||
import { PublicKeyModal } from "./PublicKeyModal";
|
||||
import { Key } from "lucide-react";
|
||||
|
||||
interface ServerFormProps {
|
||||
onSubmit: (data: CreateServerData) => void;
|
||||
@@ -14,40 +14,47 @@ interface ServerFormProps {
|
||||
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>(
|
||||
initialData ?? {
|
||||
name: '',
|
||||
ip: '',
|
||||
user: '',
|
||||
password: '',
|
||||
auth_type: 'password',
|
||||
ssh_key: '',
|
||||
ssh_key_passphrase: '',
|
||||
name: "",
|
||||
ip: "",
|
||||
user: "",
|
||||
password: "",
|
||||
auth_type: "password",
|
||||
ssh_key: "",
|
||||
ssh_key_passphrase: "",
|
||||
ssh_port: 22,
|
||||
color: '#3b82f6',
|
||||
}
|
||||
color: "#3b82f6",
|
||||
},
|
||||
);
|
||||
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof CreateServerData, string>>>({});
|
||||
const [sshKeyError, setSshKeyError] = useState<string>('');
|
||||
const [errors, setErrors] = useState<
|
||||
Partial<Record<keyof CreateServerData, string>>
|
||||
>({});
|
||||
const [sshKeyError, setSshKeyError] = useState<string>("");
|
||||
const [colorCodingEnabled, setColorCodingEnabled] = useState(false);
|
||||
const [isGeneratingKey, setIsGeneratingKey] = useState(false);
|
||||
const [showPublicKeyModal, setShowPublicKeyModal] = useState(false);
|
||||
const [generatedPublicKey, setGeneratedPublicKey] = useState('');
|
||||
const [generatedPublicKey, setGeneratedPublicKey] = useState("");
|
||||
const [, setIsGeneratedKey] = useState(false);
|
||||
const [, setGeneratedServerId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadColorCodingSetting = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/settings/color-coding');
|
||||
const response = await fetch("/api/settings/color-coding");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setColorCodingEnabled(Boolean(data.enabled));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading color coding setting:', error);
|
||||
console.error("Error loading color coding setting:", error);
|
||||
}
|
||||
};
|
||||
void loadColorCodingSetting();
|
||||
@@ -58,14 +65,15 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
||||
if (!trimmed) return false;
|
||||
|
||||
// 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)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for IPv6 with zone identifier (link-local addresses like fe80::...%eth0)
|
||||
let ipv6Address = trimmed;
|
||||
const zoneIdMatch = trimmed.match(/^(.+)%([a-zA-Z0-9_\-]+)$/);
|
||||
const zoneIdMatch = /^(.+)%([a-zA-Z0-9_\-]+)$/.exec(trimmed);
|
||||
if (zoneIdMatch) {
|
||||
ipv6Address = zoneIdMatch[1];
|
||||
// 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.
|
||||
// Also supports IPv4-mapped IPv6 addresses like ::ffff:192.168.1.1
|
||||
// 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)) {
|
||||
// Additional validation: ensure only one :: compression exists
|
||||
const compressionCount = (ipv6Address.match(/::/g) || []).length;
|
||||
const compressionCount = (ipv6Address.match(/::/g) ?? []).length;
|
||||
if (compressionCount <= 1) {
|
||||
return true;
|
||||
}
|
||||
@@ -91,17 +100,19 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
||||
// FQDN/hostname validation (RFC 1123 compliant)
|
||||
// Allows letters, numbers, hyphens, dots; must start and end with alphanumeric
|
||||
// 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) {
|
||||
// Additional check: each label (between dots) must be max 63 chars
|
||||
const labels = trimmed.split('.');
|
||||
if (labels.every(label => label.length > 0 && label.length <= 63)) {
|
||||
const labels = trimmed.split(".");
|
||||
if (labels.every((label) => label.length > 0 && label.length <= 63)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return true;
|
||||
}
|
||||
@@ -113,41 +124,44 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
||||
const newErrors: Partial<Record<keyof CreateServerData, string>> = {};
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = 'Server name is required';
|
||||
newErrors.name = "Server name is required";
|
||||
}
|
||||
|
||||
if (!formData.ip.trim()) {
|
||||
newErrors.ip = 'Server address is required';
|
||||
newErrors.ip = "Server address is required";
|
||||
} else {
|
||||
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()) {
|
||||
newErrors.user = 'Username is required';
|
||||
newErrors.user = "Username is required";
|
||||
}
|
||||
|
||||
// Validate SSH port
|
||||
if (formData.ssh_port !== undefined && (formData.ssh_port < 1 || formData.ssh_port > 65535)) {
|
||||
newErrors.ssh_port = 'SSH port must be between 1 and 65535';
|
||||
if (
|
||||
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
|
||||
const authType = formData.auth_type ?? 'password';
|
||||
|
||||
if (authType === 'password') {
|
||||
const authType = formData.auth_type ?? "password";
|
||||
|
||||
if (authType === "password") {
|
||||
if (!formData.password?.trim()) {
|
||||
newErrors.password = 'Password is required for password authentication';
|
||||
}
|
||||
}
|
||||
|
||||
if (authType === 'key') {
|
||||
if (!formData.ssh_key?.trim()) {
|
||||
newErrors.ssh_key = 'SSH key is required for key authentication';
|
||||
newErrors.password = "Password is required for password authentication";
|
||||
}
|
||||
}
|
||||
|
||||
if (authType === "key") {
|
||||
if (!formData.ssh_key?.trim()) {
|
||||
newErrors.ssh_key = "SSH key is required for key authentication";
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0 && !sshKeyError;
|
||||
@@ -158,348 +172,411 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
||||
if (validateForm()) {
|
||||
onSubmit(formData);
|
||||
if (!isEditing) {
|
||||
setFormData({
|
||||
name: '',
|
||||
ip: '',
|
||||
user: '',
|
||||
password: '',
|
||||
auth_type: 'password',
|
||||
ssh_key: '',
|
||||
ssh_key_passphrase: '',
|
||||
setFormData({
|
||||
name: "",
|
||||
ip: "",
|
||||
user: "",
|
||||
password: "",
|
||||
auth_type: "password",
|
||||
ssh_key: "",
|
||||
ssh_key_passphrase: "",
|
||||
ssh_port: 22,
|
||||
color: '#3b82f6'
|
||||
color: "#3b82f6",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (field: keyof CreateServerData) => (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
||||
) => {
|
||||
// Special handling for numeric ssh_port: keep it strictly numeric
|
||||
if (field === 'ssh_port') {
|
||||
const raw = (e.target as HTMLInputElement).value ?? '';
|
||||
const digitsOnly = raw.replace(/\D+/g, '');
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
ssh_port: digitsOnly ? parseInt(digitsOnly, 10) : undefined,
|
||||
}));
|
||||
if (errors.ssh_port) {
|
||||
setErrors(prev => ({ ...prev, ssh_port: undefined }));
|
||||
const handleChange =
|
||||
(field: keyof CreateServerData) =>
|
||||
(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
// Special handling for numeric ssh_port: keep it strictly numeric
|
||||
if (field === "ssh_port") {
|
||||
const raw = (e.target as HTMLInputElement).value ?? "";
|
||||
const digitsOnly = raw.replace(/\D+/g, "");
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
ssh_port: digitsOnly ? parseInt(digitsOnly, 10) : undefined,
|
||||
}));
|
||||
if (errors.ssh_port) {
|
||||
setErrors((prev) => ({ ...prev, ssh_port: undefined }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setFormData(prev => ({ ...prev, [field]: (e.target as HTMLInputElement).value }));
|
||||
// Clear error when user starts typing
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({ ...prev, [field]: undefined }));
|
||||
}
|
||||
|
||||
// Reset generated key state when switching auth types
|
||||
if (field === 'auth_type') {
|
||||
setIsGeneratedKey(false);
|
||||
setGeneratedPublicKey('');
|
||||
}
|
||||
};
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: (e.target as HTMLInputElement).value,
|
||||
}));
|
||||
// Clear error when user starts typing
|
||||
if (errors[field]) {
|
||||
setErrors((prev) => ({ ...prev, [field]: undefined }));
|
||||
}
|
||||
|
||||
// Reset generated key state when switching auth types
|
||||
if (field === "auth_type") {
|
||||
setIsGeneratedKey(false);
|
||||
setGeneratedPublicKey("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateKeyPair = async () => {
|
||||
setIsGeneratingKey(true);
|
||||
try {
|
||||
const response = await fetch('/api/servers/generate-keypair', {
|
||||
method: 'POST',
|
||||
const response = await fetch("/api/servers/generate-keypair", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
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) {
|
||||
const serverId = data.serverId ?? 0;
|
||||
const keyPath = `data/ssh-keys/server_${serverId}_key`;
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
ssh_key: data.privateKey ?? '',
|
||||
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
ssh_key: data.privateKey ?? "",
|
||||
ssh_key_path: keyPath,
|
||||
key_generated: true
|
||||
key_generated: true,
|
||||
}));
|
||||
setGeneratedPublicKey(data.publicKey ?? '');
|
||||
setGeneratedPublicKey(data.publicKey ?? "");
|
||||
setGeneratedServerId(serverId);
|
||||
setIsGeneratedKey(true);
|
||||
setShowPublicKeyModal(true);
|
||||
setSshKeyError('');
|
||||
setSshKeyError("");
|
||||
} else {
|
||||
throw new Error(data.error ?? 'Failed to generate key pair');
|
||||
throw new Error(data.error ?? "Failed to generate key pair");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error generating key pair:', error);
|
||||
setSshKeyError(error instanceof Error ? error.message : 'Failed to generate key pair');
|
||||
console.error("Error generating key pair:", error);
|
||||
setSshKeyError(
|
||||
error instanceof Error ? error.message : "Failed to generate key pair",
|
||||
);
|
||||
} finally {
|
||||
setIsGeneratingKey(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSSHKeyChange = (value: string) => {
|
||||
setFormData(prev => ({ ...prev, ssh_key: value }));
|
||||
setFormData((prev) => ({ ...prev, ssh_key: value }));
|
||||
if (errors.ssh_key) {
|
||||
setErrors(prev => ({ ...prev, ssh_key: undefined }));
|
||||
setErrors((prev) => ({ ...prev, ssh_key: undefined }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||
Server Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
value={formData.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 ${
|
||||
errors.name ? 'border-destructive' : 'border-border'
|
||||
}`}
|
||||
placeholder="e.g., Production Server"
|
||||
/>
|
||||
{errors.name && <p className="mt-1 text-sm text-destructive">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="ip" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||
Host/IP Address *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="ip"
|
||||
value={formData.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 ${
|
||||
errors.ip ? 'border-destructive' : 'border-border'
|
||||
}`}
|
||||
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>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="user" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||
Username *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="user"
|
||||
value={formData.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 ${
|
||||
errors.user ? 'border-destructive' : 'border-border'
|
||||
}`}
|
||||
placeholder="e.g., root"
|
||||
/>
|
||||
{errors.user && <p className="mt-1 text-sm text-destructive">{errors.user}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="ssh_port" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||
SSH Port
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="ssh_port"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
autoComplete="off"
|
||||
value={formData.ssh_port ?? 22}
|
||||
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 ${
|
||||
errors.ssh_port ? 'border-destructive' : 'border-border'
|
||||
}`}
|
||||
placeholder="22"
|
||||
min={1}
|
||||
max={65535}
|
||||
/>
|
||||
{errors.ssh_port && <p className="mt-1 text-sm text-destructive">{errors.ssh_port}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="auth_type" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||
Authentication Type *
|
||||
</label>
|
||||
<select
|
||||
id="auth_type"
|
||||
value={formData.auth_type ?? 'password'}
|
||||
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"
|
||||
>
|
||||
<option value="password">Password Only</option>
|
||||
<option value="key">SSH Key Only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{colorCodingEnabled && (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="color" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||
Server Color
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="text-muted-foreground mb-1 block text-sm font-medium"
|
||||
>
|
||||
Server Name *
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
id="color"
|
||||
value={formData.color ?? '#3b82f6'}
|
||||
onChange={handleChange('color')}
|
||||
className="w-20 h-10 rounded cursor-pointer border border-border"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Choose a color to identify this server
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange("name")}
|
||||
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"
|
||||
}`}
|
||||
placeholder="e.g., Production Server"
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-destructive mt-1 text-sm">{errors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password Authentication */}
|
||||
{formData.auth_type === 'password' && (
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||
Password *
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
value={formData.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 ${
|
||||
errors.password ? 'border-destructive' : 'border-border'
|
||||
}`}
|
||||
placeholder="Enter password"
|
||||
/>
|
||||
{errors.password && <p className="mt-1 text-sm text-destructive">{errors.password}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SSH Key Authentication */}
|
||||
{formData.auth_type === 'key' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="block text-sm font-medium text-muted-foreground">
|
||||
SSH Private Key *
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleGenerateKeyPair}
|
||||
disabled={isGeneratingKey}
|
||||
className="gap-2"
|
||||
<label
|
||||
htmlFor="ip"
|
||||
className="text-muted-foreground mb-1 block text-sm font-medium"
|
||||
>
|
||||
Host/IP Address *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="ip"
|
||||
value={formData.ip}
|
||||
onChange={handleChange("ip")}
|
||||
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"
|
||||
}`}
|
||||
placeholder="e.g., 192.168.1.100, server.example.com, 2001:db8::1, or fe80::...%eth0"
|
||||
/>
|
||||
{errors.ip && (
|
||||
<p className="text-destructive mt-1 text-sm">{errors.ip}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="user"
|
||||
className="text-muted-foreground mb-1 block text-sm font-medium"
|
||||
>
|
||||
Username *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="user"
|
||||
value={formData.user}
|
||||
onChange={handleChange("user")}
|
||||
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"
|
||||
}`}
|
||||
placeholder="e.g., root"
|
||||
/>
|
||||
{errors.user && (
|
||||
<p className="text-destructive mt-1 text-sm">{errors.user}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="ssh_port"
|
||||
className="text-muted-foreground mb-1 block text-sm font-medium"
|
||||
>
|
||||
SSH Port
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="ssh_port"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
autoComplete="off"
|
||||
value={formData.ssh_port ?? 22}
|
||||
onChange={handleChange("ssh_port")}
|
||||
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"
|
||||
}`}
|
||||
placeholder="22"
|
||||
min={1}
|
||||
max={65535}
|
||||
/>
|
||||
{errors.ssh_port && (
|
||||
<p className="text-destructive mt-1 text-sm">{errors.ssh_port}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="auth_type"
|
||||
className="text-muted-foreground mb-1 block text-sm font-medium"
|
||||
>
|
||||
Authentication Type *
|
||||
</label>
|
||||
<select
|
||||
id="auth_type"
|
||||
value={formData.auth_type ?? "password"}
|
||||
onChange={handleChange("auth_type")}
|
||||
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="key">SSH Key Only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{colorCodingEnabled && (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="color"
|
||||
className="text-muted-foreground mb-1 block text-sm font-medium"
|
||||
>
|
||||
<Key className="h-4 w-4" />
|
||||
{isGeneratingKey ? 'Generating...' : 'Generate Key Pair'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Show manual key input only if no key has been generated */}
|
||||
{!formData.key_generated && (
|
||||
<>
|
||||
<SSHKeyInput
|
||||
value={formData.ssh_key ?? ''}
|
||||
onChange={handleSSHKeyChange}
|
||||
onError={setSshKeyError}
|
||||
Server Color
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
id="color"
|
||||
value={formData.color ?? "#3b82f6"}
|
||||
onChange={handleChange("color")}
|
||||
className="border-border h-10 w-20 cursor-pointer rounded border"
|
||||
/>
|
||||
{errors.ssh_key && <p className="mt-1 text-sm text-destructive">{errors.ssh_key}</p>}
|
||||
{sshKeyError && <p className="mt-1 text-sm text-destructive">{sshKeyError}</p>}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Show generated key status */}
|
||||
{formData.key_generated && (
|
||||
<div className="p-3 bg-success/10 border border-success/20 rounded-md">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-success-foreground">
|
||||
SSH key pair generated successfully
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowPublicKeyModal(true)}
|
||||
className="gap-2 border-info/20 text-info bg-info/10 hover:bg-info/20"
|
||||
>
|
||||
<Key className="h-4 w-4" />
|
||||
View Public Key
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-success/80 mt-1">
|
||||
The private key has been generated and will be saved with the server.
|
||||
</p>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Choose a color to identify this server
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password Authentication */}
|
||||
{formData.auth_type === "password" && (
|
||||
<div>
|
||||
<label htmlFor="ssh_key_passphrase" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||
SSH Key Passphrase (Optional)
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="text-muted-foreground mb-1 block text-sm font-medium"
|
||||
>
|
||||
Password *
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="ssh_key_passphrase"
|
||||
value={formData.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"
|
||||
placeholder="Enter passphrase for encrypted key"
|
||||
id="password"
|
||||
value={formData.password ?? ""}
|
||||
onChange={handleChange("password")}
|
||||
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"
|
||||
}`}
|
||||
placeholder="Enter password"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Only required if your SSH key is encrypted with a passphrase
|
||||
</p>
|
||||
{errors.password && (
|
||||
<p className="text-destructive mt-1 text-sm">{errors.password}</p>
|
||||
)}
|
||||
</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">
|
||||
{isEditing && onCancel && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="w-full sm:w-auto order-2 sm:order-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
size="default"
|
||||
className="w-full sm:w-auto order-1 sm:order-2"
|
||||
>
|
||||
{isEditing ? 'Update Server' : 'Add Server'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Public Key Modal */}
|
||||
<PublicKeyModal
|
||||
isOpen={showPublicKeyModal}
|
||||
onClose={() => setShowPublicKeyModal(false)}
|
||||
publicKey={generatedPublicKey}
|
||||
serverName={formData.name || 'New Server'}
|
||||
serverIp={formData.ip}
|
||||
/>
|
||||
|
||||
{/* SSH Key Authentication */}
|
||||
{formData.auth_type === "key" && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<label className="text-muted-foreground block text-sm font-medium">
|
||||
SSH Private Key *
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleGenerateKeyPair}
|
||||
disabled={isGeneratingKey}
|
||||
className="gap-2"
|
||||
>
|
||||
<Key className="h-4 w-4" />
|
||||
{isGeneratingKey ? "Generating..." : "Generate Key Pair"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Show manual key input only if no key has been generated */}
|
||||
{!formData.key_generated && (
|
||||
<>
|
||||
<SSHKeyInput
|
||||
value={formData.ssh_key ?? ""}
|
||||
onChange={handleSSHKeyChange}
|
||||
onError={setSshKeyError}
|
||||
/>
|
||||
{errors.ssh_key && (
|
||||
<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 */}
|
||||
{formData.key_generated && (
|
||||
<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 gap-2">
|
||||
<svg
|
||||
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>
|
||||
<span className="text-success-foreground text-sm font-medium">
|
||||
SSH key pair generated successfully
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowPublicKeyModal(true)}
|
||||
className="border-info/20 text-info bg-info/10 hover:bg-info/20 gap-2"
|
||||
>
|
||||
<Key className="h-4 w-4" />
|
||||
View Public Key
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-success/80 mt-1 text-xs">
|
||||
The private key has been generated and will be saved with
|
||||
the server.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="ssh_key_passphrase"
|
||||
className="text-muted-foreground mb-1 block text-sm font-medium"
|
||||
>
|
||||
SSH Key Passphrase (Optional)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="ssh_key_passphrase"
|
||||
value={formData.ssh_key_passphrase ?? ""}
|
||||
onChange={handleChange("ssh_key_passphrase")}
|
||||
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"
|
||||
/>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Only required if your SSH key is encrypted with a passphrase
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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 && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="order-2 w-full sm:order-1 sm:w-auto"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
size="default"
|
||||
className="order-1 w-full sm:order-2 sm:w-auto"
|
||||
>
|
||||
{isEditing ? "Update Server" : "Add Server"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Public Key Modal */}
|
||||
<PublicKeyModal
|
||||
isOpen={showPublicKeyModal}
|
||||
onClose={() => setShowPublicKeyModal(false)}
|
||||
publicKey={generatedPublicKey}
|
||||
serverName={formData.name || "New Server"}
|
||||
serverIp={formData.ip}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Database, RefreshCw, CheckCircle, 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';
|
||||
import { useState } from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
Database,
|
||||
RefreshCw,
|
||||
CheckCircle,
|
||||
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 {
|
||||
isOpen: boolean;
|
||||
@@ -19,30 +25,38 @@ export function ServerStoragesModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
serverId,
|
||||
serverName
|
||||
serverName,
|
||||
}: ServerStoragesModalProps) {
|
||||
const [forceRefresh, setForceRefresh] = useState(false);
|
||||
const [selectedPBSStorage, setSelectedPBSStorage] = useState<Storage | null>(null);
|
||||
|
||||
const { data, isLoading, refetch } = api.installedScripts.getBackupStorages.useQuery(
|
||||
{ serverId, forceRefresh },
|
||||
{ enabled: isOpen }
|
||||
const [selectedPBSStorage, setSelectedPBSStorage] = useState<Storage | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
|
||||
const { data, isLoading, refetch } =
|
||||
api.installedScripts.getBackupStorages.useQuery(
|
||||
{ serverId, forceRefresh },
|
||||
{ enabled: isOpen },
|
||||
);
|
||||
|
||||
// Fetch all PBS credentials for this server to show status indicators
|
||||
const { data: allCredentials } = api.pbsCredentials.getAllCredentialsForServer.useQuery(
|
||||
{ serverId },
|
||||
{ enabled: isOpen }
|
||||
);
|
||||
|
||||
const { data: allCredentials } =
|
||||
api.pbsCredentials.getAllCredentialsForServer.useQuery(
|
||||
{ serverId },
|
||||
{ enabled: isOpen },
|
||||
);
|
||||
|
||||
const credentialsMap = new Map<string, boolean>();
|
||||
if (allCredentials?.success) {
|
||||
allCredentials.credentials.forEach(c => {
|
||||
allCredentials.credentials.forEach((c) => {
|
||||
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 = () => {
|
||||
setForceRefresh(true);
|
||||
@@ -53,16 +67,16 @@ export function ServerStoragesModal({
|
||||
if (!isOpen) return null;
|
||||
|
||||
const storages = data?.success ? data.storages : [];
|
||||
const backupStorages = storages.filter(s => s.supportsBackup);
|
||||
const backupStorages = storages.filter((s) => s.supportsBackup);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-3xl w-full max-h-[90vh] flex flex-col border border-border">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
|
||||
<div className="bg-card border-border flex max-h-[90vh] w-full max-w-3xl flex-col rounded-lg border shadow-xl">
|
||||
{/* 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">
|
||||
<Database className="h-6 w-6 text-primary" />
|
||||
<h2 className="text-2xl font-bold text-card-foreground">
|
||||
<Database className="text-primary h-6 w-6" />
|
||||
<h2 className="text-card-foreground text-2xl font-bold">
|
||||
Storages for {serverName}
|
||||
</h2>
|
||||
</div>
|
||||
@@ -73,7 +87,9 @@ export function ServerStoragesModal({
|
||||
size="sm"
|
||||
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
|
||||
</Button>
|
||||
<Button
|
||||
@@ -82,8 +98,18 @@ export function ServerStoragesModal({
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
<svg
|
||||
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>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -92,96 +118,112 @@ export function ServerStoragesModal({
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-4"></div>
|
||||
<div className="py-8 text-center">
|
||||
<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>
|
||||
</div>
|
||||
) : !data?.success ? (
|
||||
<div className="text-center py-8">
|
||||
<Database className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<div className="py-8 text-center">
|
||||
<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-sm text-muted-foreground mb-4">
|
||||
{data?.error ?? 'Unknown error occurred'}
|
||||
<p className="text-muted-foreground mb-4 text-sm">
|
||||
{data?.error ?? "Unknown error occurred"}
|
||||
</p>
|
||||
<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
|
||||
</Button>
|
||||
</div>
|
||||
) : storages.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Database className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<div className="py-8 text-center">
|
||||
<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-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Make sure your server has storages configured.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{data.cached && (
|
||||
<div className="mb-4 p-3 bg-muted/50 rounded-lg text-sm text-muted-foreground">
|
||||
Showing cached data. Click Refresh to fetch latest from server.
|
||||
<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.
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div className="space-y-3">
|
||||
{storages.map((storage) => {
|
||||
const isBackupCapable = storage.supportsBackup;
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
key={storage.name}
|
||||
className={`p-4 border rounded-lg ${
|
||||
className={`rounded-lg border p-4 ${
|
||||
isBackupCapable
|
||||
? 'border-success/50 bg-success/5'
|
||||
: 'border-border bg-card'
|
||||
? "border-success/50 bg-success/5"
|
||||
: "border-border bg-card"
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<h3 className="font-medium text-foreground">{storage.name}</h3>
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-foreground font-medium">
|
||||
{storage.name}
|
||||
</h3>
|
||||
{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" />
|
||||
Backup
|
||||
</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}
|
||||
</span>
|
||||
{storage.type === 'pbs' && (
|
||||
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">
|
||||
{storage.type === "pbs" &&
|
||||
(credentialsMap.has(storage.name) ? (
|
||||
<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" />
|
||||
Credentials Configured
|
||||
</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" />
|
||||
Credentials Needed
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
<div className="text-muted-foreground space-y-1 text-sm">
|
||||
<div>
|
||||
<span className="font-medium">Content:</span> {storage.content.join(', ')}
|
||||
<span className="font-medium">Content:</span>{" "}
|
||||
{storage.content.join(", ")}
|
||||
</div>
|
||||
{storage.nodes && storage.nodes.length > 0 && (
|
||||
<div>
|
||||
<span className="font-medium">Nodes:</span> {storage.nodes.join(', ')}
|
||||
<span className="font-medium">Nodes:</span>{" "}
|
||||
{storage.nodes.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
{Object.entries(storage)
|
||||
.filter(([key]) => !['name', 'type', 'content', 'supportsBackup', 'nodes'].includes(key))
|
||||
.filter(
|
||||
([key]) =>
|
||||
![
|
||||
"name",
|
||||
"type",
|
||||
"content",
|
||||
"supportsBackup",
|
||||
"nodes",
|
||||
].includes(key),
|
||||
)
|
||||
.map(([key, value]) => (
|
||||
<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>
|
||||
{storage.type === 'pbs' && (
|
||||
<div className="mt-3 pt-3 border-t border-border">
|
||||
{storage.type === "pbs" && (
|
||||
<div className="border-border mt-3 border-t pt-3">
|
||||
<Button
|
||||
onClick={() => setSelectedPBSStorage(storage)}
|
||||
variant="outline"
|
||||
@@ -189,7 +231,10 @@ export function ServerStoragesModal({
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Lock className="h-4 w-4" />
|
||||
{credentialsMap.has(storage.name) ? 'Edit' : 'Configure'} Credentials
|
||||
{credentialsMap.has(storage.name)
|
||||
? "Edit"
|
||||
: "Configure"}{" "}
|
||||
Credentials
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -198,11 +243,13 @@ export function ServerStoragesModal({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
|
||||
{backupStorages.length > 0 && (
|
||||
<div className="mt-6 p-4 bg-success/10 border border-success/20 rounded-lg">
|
||||
<p className="text-sm text-success font-medium">
|
||||
{backupStorages.length} storage{backupStorages.length !== 1 ? 's' : ''} available for backups
|
||||
<div className="bg-success/10 border-success/20 mt-6 rounded-lg border p-4">
|
||||
<p className="text-success text-sm font-medium">
|
||||
{backupStorages.length} storage
|
||||
{backupStorages.length !== 1 ? "s" : ""} available for
|
||||
backups
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -210,7 +257,7 @@ export function ServerStoragesModal({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* PBS Credentials Modal */}
|
||||
{selectedPBSStorage && (
|
||||
<PBSCredentialsModal
|
||||
@@ -224,4 +271,3 @@ export function ServerStoragesModal({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { tomorrow } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { Button } from './ui/button';
|
||||
import type { Script } from '../../types/script';
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { tomorrow } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import { Button } from "./ui/button";
|
||||
import type { Script } from "../../types/script";
|
||||
|
||||
interface TextViewerProps {
|
||||
scriptName: string;
|
||||
@@ -20,113 +20,155 @@ interface ScriptContent {
|
||||
alpineInstallScript?: string;
|
||||
}
|
||||
|
||||
export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerProps) {
|
||||
export function TextViewer({
|
||||
scriptName,
|
||||
isOpen,
|
||||
onClose,
|
||||
script,
|
||||
}: TextViewerProps) {
|
||||
const [scriptContent, setScriptContent] = useState<ScriptContent>({});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'main' | 'install'>('main');
|
||||
const [selectedVersion, setSelectedVersion] = useState<'default' | 'alpine'>('default');
|
||||
const [activeTab, setActiveTab] = useState<"main" | "install">("main");
|
||||
const [selectedVersion, setSelectedVersion] = useState<"default" | "alpine">(
|
||||
"default",
|
||||
);
|
||||
|
||||
// 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
|
||||
const defaultMethod = script?.install_methods?.find(method => method.type === 'default');
|
||||
const alpineMethod = script?.install_methods?.find(method => method.type === 'alpine');
|
||||
|
||||
const defaultMethod = script?.install_methods?.find(
|
||||
(method) => method.type === "default",
|
||||
);
|
||||
const alpineMethod = script?.install_methods?.find(
|
||||
(method) => method.type === "alpine",
|
||||
);
|
||||
|
||||
// Check if alpine variant exists
|
||||
const hasAlpineVariant = !!alpineMethod;
|
||||
|
||||
|
||||
// Get script paths from install_methods
|
||||
const defaultScriptPath = defaultMethod?.script;
|
||||
const alpineScriptPath = alpineMethod?.script;
|
||||
|
||||
|
||||
// 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
|
||||
const defaultScriptName = scriptName.replace(/^alpine-/, '');
|
||||
const alpineScriptName = scriptName.startsWith('alpine-') ? scriptName : `alpine-${scriptName}`;
|
||||
const defaultScriptName = scriptName.replace(/^alpine-/, "");
|
||||
|
||||
const loadScriptContent = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
|
||||
try {
|
||||
// Build fetch requests based on actual script paths from install_methods
|
||||
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.)
|
||||
if (defaultScriptPath) {
|
||||
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)
|
||||
if (hasInstallScript && defaultScriptPath?.startsWith('ct/')) {
|
||||
if (hasInstallScript && defaultScriptPath?.startsWith("ct/")) {
|
||||
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
|
||||
if (hasAlpineVariant && alpineScriptPath) {
|
||||
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)
|
||||
if (hasAlpineVariant && hasInstallScript && alpineScriptPath?.startsWith('ct/')) {
|
||||
if (
|
||||
hasAlpineVariant &&
|
||||
hasInstallScript &&
|
||||
alpineScriptPath?.startsWith("ct/")
|
||||
) {
|
||||
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 content: ScriptContent = {};
|
||||
|
||||
// Process responses based on their types
|
||||
await Promise.all(responses.map(async (response, index) => {
|
||||
if (response.status === 'fulfilled' && response.value.ok) {
|
||||
try {
|
||||
const data = await response.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
|
||||
const type = requestTypes[index];
|
||||
if (data.result?.data?.json?.success && data.result.data.json.content) {
|
||||
switch (type) {
|
||||
case 'default-main':
|
||||
content.mainScript = data.result.data.json.content;
|
||||
break;
|
||||
case 'default-install':
|
||||
content.installScript = data.result.data.json.content;
|
||||
break;
|
||||
case 'alpine-main':
|
||||
content.alpineMainScript = data.result.data.json.content;
|
||||
break;
|
||||
case 'alpine-install':
|
||||
content.alpineInstallScript = data.result.data.json.content;
|
||||
break;
|
||||
await Promise.all(
|
||||
responses.map(async (response, index) => {
|
||||
if (response.status === "fulfilled" && response.value.ok) {
|
||||
try {
|
||||
const data = (await response.value.json()) as {
|
||||
result?: {
|
||||
data?: { json?: { success?: boolean; content?: string } };
|
||||
};
|
||||
};
|
||||
const type = requestTypes[index];
|
||||
if (
|
||||
data.result?.data?.json?.success &&
|
||||
data.result.data.json.content
|
||||
) {
|
||||
switch (type) {
|
||||
case "default-main":
|
||||
content.mainScript = data.result.data.json.content;
|
||||
break;
|
||||
case "default-install":
|
||||
content.installScript = data.result.data.json.content;
|
||||
break;
|
||||
case "alpine-main":
|
||||
content.alpineMainScript = data.result.data.json.content;
|
||||
break;
|
||||
case "alpine-install":
|
||||
content.alpineInstallScript = data.result.data.json.content;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
}));
|
||||
}),
|
||||
);
|
||||
|
||||
setScriptContent(content);
|
||||
} 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 {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [defaultScriptPath, alpineScriptPath, slug, hasAlpineVariant, hasInstallScript]);
|
||||
}, [
|
||||
defaultScriptPath,
|
||||
alpineScriptPath,
|
||||
slug,
|
||||
hasAlpineVariant,
|
||||
hasInstallScript,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && scriptName) {
|
||||
@@ -144,48 +186,53 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
|
||||
|
||||
return (
|
||||
<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}
|
||||
>
|
||||
<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 */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||
<div className="flex items-center space-x-4 flex-1">
|
||||
<h2 className="text-2xl font-bold text-foreground">
|
||||
<div className="border-border flex items-center justify-between border-b p-6">
|
||||
<div className="flex flex-1 items-center space-x-4">
|
||||
<h2 className="text-foreground text-2xl font-bold">
|
||||
Script Viewer: {defaultScriptName}
|
||||
</h2>
|
||||
{hasAlpineVariant && (
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant={selectedVersion === 'default' ? 'default' : 'outline'}
|
||||
onClick={() => setSelectedVersion('default')}
|
||||
variant={
|
||||
selectedVersion === "default" ? "default" : "outline"
|
||||
}
|
||||
onClick={() => setSelectedVersion("default")}
|
||||
className="px-3 py-1 text-sm"
|
||||
>
|
||||
Default
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedVersion === 'alpine' ? 'default' : 'outline'}
|
||||
onClick={() => setSelectedVersion('alpine')}
|
||||
variant={selectedVersion === "alpine" ? "default" : "outline"}
|
||||
onClick={() => setSelectedVersion("alpine")}
|
||||
className="px-3 py-1 text-sm"
|
||||
>
|
||||
Alpine
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{((selectedVersion === 'default' && (scriptContent.mainScript || scriptContent.installScript)) ||
|
||||
(selectedVersion === 'alpine' && (scriptContent.alpineMainScript || scriptContent.alpineInstallScript))) && (
|
||||
{((selectedVersion === "default" &&
|
||||
(scriptContent.mainScript || scriptContent.installScript)) ||
|
||||
(selectedVersion === "alpine" &&
|
||||
(scriptContent.alpineMainScript ||
|
||||
scriptContent.alpineInstallScript))) && (
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant={activeTab === 'main' ? 'outline' : 'ghost'}
|
||||
onClick={() => setActiveTab('main')}
|
||||
variant={activeTab === "main" ? "outline" : "ghost"}
|
||||
onClick={() => setActiveTab("main")}
|
||||
className="px-3 py-1 text-sm"
|
||||
>
|
||||
Script
|
||||
</Button>
|
||||
{hasInstallScript && (
|
||||
<Button
|
||||
variant={activeTab === 'install' ? 'outline' : 'ghost'}
|
||||
onClick={() => setActiveTab('install')}
|
||||
variant={activeTab === "install" ? "outline" : "ghost"}
|
||||
onClick={() => setActiveTab("install")}
|
||||
className="px-3 py-1 text-sm"
|
||||
>
|
||||
Install Script
|
||||
@@ -198,51 +245,64 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
<svg
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-lg text-muted-foreground">Loading script content...</div>
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-muted-foreground text-lg">
|
||||
Loading script content...
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-lg text-destructive">Error: {error}</div>
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-destructive text-lg">Error: {error}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-auto">
|
||||
{activeTab === 'main' && (
|
||||
selectedVersion === 'default' && scriptContent.mainScript ? (
|
||||
{activeTab === "main" &&
|
||||
(selectedVersion === "default" && scriptContent.mainScript ? (
|
||||
<SyntaxHighlighter
|
||||
language="bash"
|
||||
style={tomorrow}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '1rem',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
minHeight: '100%'
|
||||
padding: "1rem",
|
||||
fontSize: "14px",
|
||||
lineHeight: "1.5",
|
||||
minHeight: "100%",
|
||||
}}
|
||||
showLineNumbers={true}
|
||||
wrapLines={true}
|
||||
>
|
||||
{scriptContent.mainScript}
|
||||
</SyntaxHighlighter>
|
||||
) : selectedVersion === 'alpine' && scriptContent.alpineMainScript ? (
|
||||
) : selectedVersion === "alpine" &&
|
||||
scriptContent.alpineMainScript ? (
|
||||
<SyntaxHighlighter
|
||||
language="bash"
|
||||
style={tomorrow}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '1rem',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
minHeight: '100%'
|
||||
padding: "1rem",
|
||||
fontSize: "14px",
|
||||
lineHeight: "1.5",
|
||||
minHeight: "100%",
|
||||
}}
|
||||
showLineNumbers={true}
|
||||
wrapLines={true}
|
||||
@@ -250,40 +310,43 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
|
||||
{scriptContent.alpineMainScript}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-lg text-muted-foreground">
|
||||
{selectedVersion === 'default' ? 'Default script not found' : 'Alpine script not found'}
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-muted-foreground text-lg">
|
||||
{selectedVersion === "default"
|
||||
? "Default script not found"
|
||||
: "Alpine script not found"}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{activeTab === 'install' && (
|
||||
selectedVersion === 'default' && scriptContent.installScript ? (
|
||||
))}
|
||||
{activeTab === "install" &&
|
||||
(selectedVersion === "default" &&
|
||||
scriptContent.installScript ? (
|
||||
<SyntaxHighlighter
|
||||
language="bash"
|
||||
style={tomorrow}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '1rem',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
minHeight: '100%'
|
||||
padding: "1rem",
|
||||
fontSize: "14px",
|
||||
lineHeight: "1.5",
|
||||
minHeight: "100%",
|
||||
}}
|
||||
showLineNumbers={true}
|
||||
wrapLines={true}
|
||||
>
|
||||
{scriptContent.installScript}
|
||||
</SyntaxHighlighter>
|
||||
) : selectedVersion === 'alpine' && scriptContent.alpineInstallScript ? (
|
||||
) : selectedVersion === "alpine" &&
|
||||
scriptContent.alpineInstallScript ? (
|
||||
<SyntaxHighlighter
|
||||
language="bash"
|
||||
style={tomorrow}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '1rem',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
minHeight: '100%'
|
||||
padding: "1rem",
|
||||
fontSize: "14px",
|
||||
lineHeight: "1.5",
|
||||
minHeight: "100%",
|
||||
}}
|
||||
showLineNumbers={true}
|
||||
wrapLines={true}
|
||||
@@ -291,13 +354,14 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
|
||||
{scriptContent.alpineInstallScript}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-lg text-muted-foreground">
|
||||
{selectedVersion === 'default' ? 'Default install script not found' : 'Alpine install script not found'}
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-muted-foreground text-lg">
|
||||
{selectedVersion === "default"
|
||||
? "Default install script not found"
|
||||
: "Alpine install script not found"}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { api } from '~/trpc/react';
|
||||
import { Button } from './ui/button';
|
||||
import { Badge } from './ui/badge';
|
||||
import { X, ExternalLink, Calendar, Tag, Loader2, AlertTriangle } from 'lucide-react';
|
||||
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { Button } from "./ui/button";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { X, ExternalLink, Calendar, Tag, AlertTriangle } from "lucide-react";
|
||||
import { useRegisterModal } from "./modal/ModalStackProvider";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
interface UpdateConfirmationModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -23,28 +22,34 @@ interface UpdateConfirmationModalProps {
|
||||
latestVersion: string;
|
||||
}
|
||||
|
||||
export function UpdateConfirmationModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
export function UpdateConfirmationModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
releaseInfo,
|
||||
currentVersion,
|
||||
latestVersion
|
||||
latestVersion,
|
||||
}: UpdateConfirmationModalProps) {
|
||||
useRegisterModal(isOpen, { id: 'update-confirmation-modal', allowEscape: true, onClose });
|
||||
useRegisterModal(isOpen, {
|
||||
id: "update-confirmation-modal",
|
||||
allowEscape: true,
|
||||
onClose,
|
||||
});
|
||||
|
||||
if (!isOpen || !releaseInfo) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] flex flex-col border border-border">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
|
||||
<div className="bg-card border-border flex max-h-[90vh] w-full max-w-4xl flex-col rounded-lg border shadow-xl">
|
||||
{/* 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">
|
||||
<AlertTriangle className="h-6 w-6 text-warning" />
|
||||
<AlertTriangle className="text-warning h-6 w-6" />
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-card-foreground">Confirm Update</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
<h2 className="text-card-foreground text-2xl font-bold">
|
||||
Confirm Update
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
Review the changelog before proceeding with the update
|
||||
</p>
|
||||
</div>
|
||||
@@ -60,13 +65,13 @@ export function UpdateConfirmationModal({
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex-1 space-y-4 overflow-y-auto p-6">
|
||||
{/* Version Info */}
|
||||
<div className="bg-muted/50 rounded-lg p-4 border border-border">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="bg-muted/50 border-border rounded-lg border p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<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}
|
||||
</h3>
|
||||
<Badge variant="default" className="text-xs">
|
||||
@@ -89,7 +94,7 @@ export function UpdateConfirmationModal({
|
||||
</a>
|
||||
</Button>
|
||||
</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">
|
||||
<Tag className="h-4 w-4" />
|
||||
<span>{releaseInfo.tagName}</span>
|
||||
@@ -97,40 +102,92 @@ export function UpdateConfirmationModal({
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>
|
||||
{new Date(releaseInfo.publishedAt).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}
|
||||
{new Date(releaseInfo.publishedAt).toLocaleDateString(
|
||||
"en-US",
|
||||
{
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
<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 className="font-medium text-card-foreground">v{latestVersion}</span>
|
||||
<span className="text-card-foreground font-medium">
|
||||
v{latestVersion}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Changelog */}
|
||||
{releaseInfo.body ? (
|
||||
<div className="border rounded-lg p-6 border-border bg-card">
|
||||
<h4 className="text-md font-semibold text-card-foreground mb-4">Changelog</h4>
|
||||
<div className="prose prose-sm max-w-none dark:prose-invert">
|
||||
<ReactMarkdown
|
||||
<div className="border-border bg-card rounded-lg border p-6">
|
||||
<h4 className="text-md text-card-foreground mb-4 font-semibold">
|
||||
Changelog
|
||||
</h4>
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({children}) => <h1 className="text-2xl font-bold text-card-foreground mb-4 mt-6">{children}</h1>,
|
||||
h2: ({children}) => <h2 className="text-xl font-semibold text-card-foreground mb-3 mt-5">{children}</h2>,
|
||||
h3: ({children}) => <h3 className="text-lg font-medium text-card-foreground mb-2 mt-4">{children}</h3>,
|
||||
p: ({children}) => <p className="text-card-foreground mb-3 leading-relaxed">{children}</p>,
|
||||
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>,
|
||||
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="font-semibold text-card-foreground">{children}</strong>,
|
||||
em: ({children}) => <em className="italic text-card-foreground">{children}</em>,
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-card-foreground mt-6 mb-4 text-2xl font-bold">
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-card-foreground mt-5 mb-3 text-xl font-semibold">
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
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}
|
||||
@@ -138,20 +195,23 @@ export function UpdateConfirmationModal({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg p-6 border-border bg-card">
|
||||
<p className="text-muted-foreground">No changelog available for this release.</p>
|
||||
<div className="border-border bg-card rounded-lg border p-6">
|
||||
<p className="text-muted-foreground">
|
||||
No changelog available for this release.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
<AlertTriangle className="h-5 w-5 text-warning mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm text-card-foreground">
|
||||
<p className="font-medium mb-1">Important:</p>
|
||||
<AlertTriangle className="text-warning mt-0.5 h-5 w-5 flex-shrink-0" />
|
||||
<div className="text-card-foreground text-sm">
|
||||
<p className="mb-1 font-medium">Important:</p>
|
||||
<p className="text-muted-foreground">
|
||||
Please review the changelog above for any breaking changes or important updates before proceeding.
|
||||
The server will restart automatically after the update completes.
|
||||
Please review the changelog above for any breaking changes
|
||||
or important updates before proceeding. The server will
|
||||
restart automatically after the update completes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -160,7 +220,7 @@ export function UpdateConfirmationModal({
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
Cancel
|
||||
</Button>
|
||||
@@ -172,5 +232,3 @@ export function UpdateConfirmationModal({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
278
src/app/page.tsx
278
src/app/page.tsx
@@ -1,51 +1,70 @@
|
||||
"use client";
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { ScriptsGrid } from './_components/ScriptsGrid';
|
||||
import { DownloadedScriptsTab } from './_components/DownloadedScriptsTab';
|
||||
import { InstalledScriptsTab } from './_components/InstalledScriptsTab';
|
||||
import { BackupsTab } from './_components/BackupsTab';
|
||||
import { ResyncButton } from './_components/ResyncButton';
|
||||
import { Terminal } from './_components/Terminal';
|
||||
import { ServerSettingsButton } from './_components/ServerSettingsButton';
|
||||
import { SettingsButton } from './_components/SettingsButton';
|
||||
import { HelpButton } from './_components/HelpButton';
|
||||
import { VersionDisplay } from './_components/VersionDisplay';
|
||||
import { ThemeToggle } from './_components/ThemeToggle';
|
||||
import { Button } from './_components/ui/button';
|
||||
import { ContextualHelpIcon } from './_components/ContextualHelpIcon';
|
||||
import { ReleaseNotesModal, getLastSeenVersion } from './_components/ReleaseNotesModal';
|
||||
import { Footer } from './_components/Footer';
|
||||
import { Package, HardDrive, FolderOpen, LogOut, Archive } from 'lucide-react';
|
||||
import { api } from '~/trpc/react';
|
||||
import { useAuth } from './_components/AuthProvider';
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { ScriptsGrid } from "./_components/ScriptsGrid";
|
||||
import { DownloadedScriptsTab } from "./_components/DownloadedScriptsTab";
|
||||
import { InstalledScriptsTab } from "./_components/InstalledScriptsTab";
|
||||
import { BackupsTab } from "./_components/BackupsTab";
|
||||
import { ResyncButton } from "./_components/ResyncButton";
|
||||
import { Terminal } from "./_components/Terminal";
|
||||
import { ServerSettingsButton } from "./_components/ServerSettingsButton";
|
||||
import { SettingsButton } from "./_components/SettingsButton";
|
||||
import { HelpButton } from "./_components/HelpButton";
|
||||
import { VersionDisplay } from "./_components/VersionDisplay";
|
||||
import { ThemeToggle } from "./_components/ThemeToggle";
|
||||
import { Button } from "./_components/ui/button";
|
||||
import { ContextualHelpIcon } from "./_components/ContextualHelpIcon";
|
||||
import {
|
||||
ReleaseNotesModal,
|
||||
getLastSeenVersion,
|
||||
} from "./_components/ReleaseNotesModal";
|
||||
import { Footer } from "./_components/Footer";
|
||||
import { Package, HardDrive, FolderOpen, LogOut, Archive } from "lucide-react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useAuth } from "./_components/AuthProvider";
|
||||
import type { Server } from "~/types/server";
|
||||
|
||||
export default function Home() {
|
||||
const { isAuthenticated, logout } = useAuth();
|
||||
const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | 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';
|
||||
const [runningScript, setRunningScript] = useState<{
|
||||
path: string;
|
||||
name: string;
|
||||
mode?: "local" | "ssh";
|
||||
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 [highlightVersion, setHighlightVersion] = useState<string | undefined>(undefined);
|
||||
const [highlightVersion, setHighlightVersion] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Fetch data for script counts
|
||||
const { data: scriptCardsData } = api.scripts.getScriptCardsWithCategories.useQuery();
|
||||
const { data: localScriptsData } = api.scripts.getAllDownloadedScripts.useQuery();
|
||||
const { data: installedScriptsData } = api.installedScripts.getAllInstalledScripts.useQuery();
|
||||
const { data: scriptCardsData } =
|
||||
api.scripts.getScriptCardsWithCategories.useQuery();
|
||||
const { data: localScriptsData } =
|
||||
api.scripts.getAllDownloadedScripts.useQuery();
|
||||
const { data: installedScriptsData } =
|
||||
api.installedScripts.getAllInstalledScripts.useQuery();
|
||||
const { data: backupsData } = api.backups.getAllBackupsGrouped.useQuery();
|
||||
const { data: versionData } = api.version.getCurrentVersion.useQuery();
|
||||
|
||||
// Save active tab to localStorage whenever it changes
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('activeTab', activeTab);
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("activeTab", activeTab);
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
@@ -54,9 +73,12 @@ export default function Home() {
|
||||
if (versionData?.success && versionData.version) {
|
||||
const currentVersion = versionData.version;
|
||||
const lastSeenVersion = getLastSeenVersion();
|
||||
|
||||
|
||||
// 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);
|
||||
setReleaseNotesOpen(true);
|
||||
}
|
||||
@@ -77,11 +99,11 @@ export default function Home() {
|
||||
const scriptCounts = {
|
||||
available: (() => {
|
||||
if (!scriptCardsData?.success) return 0;
|
||||
|
||||
|
||||
// Deduplicate scripts using Map by slug (same logic as ScriptsGrid.tsx)
|
||||
const scriptMap = new Map<string, any>();
|
||||
|
||||
scriptCardsData.cards?.forEach(script => {
|
||||
|
||||
scriptCardsData.cards?.forEach((script) => {
|
||||
if (script?.name && script?.slug) {
|
||||
// Use slug as unique identifier, only keep first occurrence
|
||||
if (!scriptMap.has(script.slug)) {
|
||||
@@ -89,42 +111,43 @@ export default function Home() {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return scriptMap.size;
|
||||
})(),
|
||||
downloaded: (() => {
|
||||
if (!scriptCardsData?.success || !localScriptsData?.scripts) return 0;
|
||||
|
||||
|
||||
// Helper to normalize identifiers for robust matching
|
||||
const normalizeId = (s?: string): string => (s ?? '')
|
||||
.toLowerCase()
|
||||
.replace(/\.(sh|bash|py|js|ts)$/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
const normalizeId = (s?: string): string =>
|
||||
(s ?? "")
|
||||
.toLowerCase()
|
||||
.replace(/\.(sh|bash|py|js|ts)$/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
|
||||
// First deduplicate GitHub scripts using Map by slug
|
||||
const scriptMap = new Map<string, any>();
|
||||
|
||||
scriptCardsData.cards?.forEach(script => {
|
||||
|
||||
scriptCardsData.cards?.forEach((script) => {
|
||||
if (script?.name && script?.slug) {
|
||||
if (!scriptMap.has(script.slug)) {
|
||||
scriptMap.set(script.slug, script);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const deduplicatedGithubScripts = Array.from(scriptMap.values());
|
||||
const localScripts = localScriptsData.scripts ?? [];
|
||||
|
||||
|
||||
// Count scripts that are both in deduplicated GitHub data and have local versions
|
||||
// Use the same matching logic as DownloadedScriptsTab and ScriptsGrid
|
||||
return deduplicatedGithubScripts.filter(script => {
|
||||
return deduplicatedGithubScripts.filter((script) => {
|
||||
if (!script?.name) return false;
|
||||
|
||||
|
||||
// Check if there's a corresponding local script
|
||||
return localScripts.some(local => {
|
||||
return localScripts.some((local) => {
|
||||
if (!local?.name) return false;
|
||||
|
||||
|
||||
// Primary: Exact slug-to-slug matching (most reliable)
|
||||
if (local.slug && script.slug) {
|
||||
if (local.slug.toLowerCase() === script.slug.toLowerCase()) {
|
||||
@@ -135,23 +158,29 @@ export default function Home() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Secondary: Check install basenames (for edge cases where install script names differ from slugs)
|
||||
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;
|
||||
|
||||
|
||||
// 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 false;
|
||||
});
|
||||
}).length;
|
||||
})(),
|
||||
installed: installedScriptsData?.scripts?.length ?? 0,
|
||||
backups: backupsData?.success ? backupsData.backups.length : 0
|
||||
backups: backupsData?.success ? backupsData.backups.length : 0,
|
||||
};
|
||||
|
||||
const scrollToTerminal = () => {
|
||||
@@ -159,15 +188,20 @@ export default function Home() {
|
||||
// Get the element's position and scroll with a small offset for better mobile experience
|
||||
const elementTop = terminalRef.current.offsetTop;
|
||||
const offset = window.innerWidth < 768 ? 20 : 0; // Small offset on mobile
|
||||
|
||||
|
||||
window.scrollTo({
|
||||
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 });
|
||||
// Scroll to terminal after a short delay to ensure it's rendered
|
||||
setTimeout(scrollToTerminal, 100);
|
||||
@@ -178,16 +212,16 @@ export default function Home() {
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-2 sm:px-4 py-4 sm:py-8">
|
||||
<main className="bg-background min-h-screen">
|
||||
<div className="container mx-auto px-2 py-4 sm:px-4 sm:py-8">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-6 sm:mb-8">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div className="mb-6 text-center sm:mb-8">
|
||||
<div className="mb-2 flex items-start justify-between">
|
||||
<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>
|
||||
</h1>
|
||||
<div className="flex-1 flex justify-end items-center gap-2">
|
||||
<div className="flex flex-1 items-center justify-end gap-2">
|
||||
{isAuthenticated && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -203,8 +237,9 @@ export default function Home() {
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm sm:text-base text-muted-foreground mb-4 px-2">
|
||||
Manage and execute Proxmox helper scripts locally with live output streaming
|
||||
<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
|
||||
</p>
|
||||
<div className="flex justify-center px-2">
|
||||
<VersionDisplay onOpenReleaseNotes={handleOpenReleaseNotes} />
|
||||
@@ -213,7 +248,7 @@ export default function Home() {
|
||||
|
||||
{/* Controls */}
|
||||
<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 />
|
||||
<SettingsButton />
|
||||
<ResyncButton />
|
||||
@@ -223,72 +258,85 @@ export default function Home() {
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<div className="border-b border-border">
|
||||
<nav className="-mb-px flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-1">
|
||||
<div className="border-border border-b">
|
||||
<nav className="-mb-px flex flex-col space-y-2 sm:flex-row sm:space-y-0 sm:space-x-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="null"
|
||||
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 ${
|
||||
activeTab === 'scripts'
|
||||
? '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'
|
||||
}`}>
|
||||
onClick={() => setActiveTab("scripts")}
|
||||
className={`flex w-full items-center justify-center gap-2 px-3 py-2 text-sm sm:w-auto sm:justify-start ${
|
||||
activeTab === "scripts"
|
||||
? "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"
|
||||
}`}
|
||||
>
|
||||
<Package className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Available Scripts</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}
|
||||
</span>
|
||||
<ContextualHelpIcon section="available-scripts" tooltip="Help with Available Scripts" />
|
||||
<ContextualHelpIcon
|
||||
section="available-scripts"
|
||||
tooltip="Help with Available Scripts"
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="null"
|
||||
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 ${
|
||||
activeTab === 'downloaded'
|
||||
? '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'
|
||||
}`}>
|
||||
onClick={() => setActiveTab("downloaded")}
|
||||
className={`flex w-full items-center justify-center gap-2 px-3 py-2 text-sm sm:w-auto sm:justify-start ${
|
||||
activeTab === "downloaded"
|
||||
? "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"
|
||||
}`}
|
||||
>
|
||||
<HardDrive className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Downloaded Scripts</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}
|
||||
</span>
|
||||
<ContextualHelpIcon section="downloaded-scripts" tooltip="Help with Downloaded Scripts" />
|
||||
<ContextualHelpIcon
|
||||
section="downloaded-scripts"
|
||||
tooltip="Help with Downloaded Scripts"
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="null"
|
||||
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 ${
|
||||
activeTab === 'installed'
|
||||
? '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'
|
||||
}`}>
|
||||
onClick={() => setActiveTab("installed")}
|
||||
className={`flex w-full items-center justify-center gap-2 px-3 py-2 text-sm sm:w-auto sm:justify-start ${
|
||||
activeTab === "installed"
|
||||
? "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"
|
||||
}`}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Installed Scripts</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}
|
||||
</span>
|
||||
<ContextualHelpIcon section="installed-scripts" tooltip="Help with Installed Scripts" />
|
||||
<ContextualHelpIcon
|
||||
section="installed-scripts"
|
||||
tooltip="Help with Installed Scripts"
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="null"
|
||||
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 ${
|
||||
activeTab === 'backups'
|
||||
? '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'
|
||||
}`}>
|
||||
onClick={() => setActiveTab("backups")}
|
||||
className={`flex w-full items-center justify-center gap-2 px-3 py-2 text-sm sm:w-auto sm:justify-start ${
|
||||
activeTab === "backups"
|
||||
? "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"
|
||||
}`}
|
||||
>
|
||||
<Archive className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">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}
|
||||
</span>
|
||||
</Button>
|
||||
@@ -296,8 +344,6 @@ export default function Home() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* Running Script Terminal */}
|
||||
{runningScript && (
|
||||
<div ref={terminalRef} className="mb-8">
|
||||
@@ -311,21 +357,17 @@ export default function Home() {
|
||||
)}
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'scripts' && (
|
||||
{activeTab === "scripts" && (
|
||||
<ScriptsGrid onInstallScript={handleRunScript} />
|
||||
)}
|
||||
|
||||
{activeTab === 'downloaded' && (
|
||||
|
||||
{activeTab === "downloaded" && (
|
||||
<DownloadedScriptsTab onInstallScript={handleRunScript} />
|
||||
)}
|
||||
|
||||
{activeTab === 'installed' && (
|
||||
<InstalledScriptsTab />
|
||||
)}
|
||||
|
||||
{activeTab === 'backups' && (
|
||||
<BackupsTab />
|
||||
)}
|
||||
|
||||
{activeTab === "installed" && <InstalledScriptsTab />}
|
||||
|
||||
{activeTab === "backups" && <BackupsTab />}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
|
||||
Reference in New Issue
Block a user