* feat: implement light/dark mode theme system - Add semantic color CSS variables (success, warning, info, error) for both themes - Create ThemeProvider with React context and localStorage persistence - Add ThemeToggle component with sun/moon icons for header region - Add theme switcher in General Settings modal - Replace 200+ hardcoded Tailwind colors with CSS variables across 30+ components - Update layout.tsx to remove forced dark mode - Keep terminal colors unchanged as requested - Default to dark mode, with seamless light/dark switching Components updated: - High-priority: InstalledScriptsTab, ScriptInstallationCard, LXCSettingsModal, ScriptsGrid - All remaining component files with hardcoded colors - UI components: button, toggle, badge variants - Modal components: ErrorModal, ConfirmationModal, AuthModal, SetupModal - Form components: ServerForm, FilterBar, CategorySidebar - Display components: ScriptCard, ScriptCardList, DiffViewer, TextViewer Theme switchers: - Header: Small nuanced toggle in top-right - Settings: Detailed Light/Dark selection in General Settings * fix: resolve ESLint warnings - Fix missing dependencies in useCallback and useEffect hooks - Prefix unused parameter with underscore to satisfy ESLint rules - Build now completes without warnings * fix: improve toggle component styling for better visibility - Use explicit gray colors instead of CSS variables for toggle background - Ensure proper contrast in both light and dark modes - Toggle switches now display correctly with proper visual states * fix: improve toggle visual states for better UX - Use explicit conditional styling instead of peer classes - Active toggles now clearly show primary color background - Inactive toggles show gray background for clear distinction - Much easier to tell which toggles are on/off at a glance * fix: improve toggle contrast in dark mode - Change inactive toggle background from gray-700 to gray-600 for better visibility - Add darker border color (gray-500) for toggle handle in dark mode - Toggles now have proper contrast against dark backgrounds - Both light and dark modes now have clear visual distinction * fix: resolve dependency loop and improve dropdown styling - Fix circular dependency in InstalledScriptsTab status check - Remove fetchContainerStatuses function and inline logic in useEffect - Make all dropdown menu items grey with consistent hover effects - Update both ScriptInstallationCard and InstalledScriptsTab dropdowns - Remove unused useCallback import - Build now completes without warnings or errors * fix: restore proper button colors and eliminate dependency loop - Restore red color for Stop/Destroy buttons and green for Start buttons - Fix circular dependency by using ref for containerStatusMutation - Update both InstalledScriptsTab and ScriptInstallationCard dropdowns - Maintain grey color for other menu items (Update, Shell, Open UI, etc.) - Build now completes without warnings or dependency loops * feat: add missing hover utility classes for semantic colors - Add hover states for success, warning, info, error colors - Add hover:bg-success/20, hover:bg-error/20, etc. classes - Add hover:text-success-foreground, hover:text-error-foreground classes - Start/Stop and Destroy buttons now have proper hover effects - All dropdown menu items now have consistent hover behavior * feat: improve status cards with useful LXC container information - Replace useless 'Successful/Failed/In Progress' cards with meaningful data - Show 'Running LXC' count in green (actual running containers) - Show 'Stopped LXC' count in red (actual stopped containers) - Keep 'Total Installations' for overall count - Change layout from 4 columns to 3 columns for better spacing - Status cards now show real-time container states instead of installation status * style: center content in status cards - Add text-center class to each individual status card - Numbers and labels now centered within each card - Improves visual balance and readability - All three cards (Total, Running LXC, Stopped LXC) now have centered content
371 lines
14 KiB
TypeScript
371 lines
14 KiB
TypeScript
'use client';
|
|
|
|
import { Button } from './ui/button';
|
|
import { StatusBadge } from './Badge';
|
|
import { getContrastColor } from '../../lib/colorUtils';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
DropdownMenuSeparator,
|
|
} from './ui/dropdown-menu';
|
|
|
|
interface InstalledScript {
|
|
id: number;
|
|
script_name: string;
|
|
script_path: string;
|
|
container_id: string | null;
|
|
server_id: number | null;
|
|
server_name: string | null;
|
|
server_ip: string | null;
|
|
server_user: string | null;
|
|
server_password: string | null;
|
|
server_auth_type: string | null;
|
|
server_ssh_key: string | null;
|
|
server_ssh_key_passphrase: string | null;
|
|
server_ssh_port: number | null;
|
|
server_color: string | null;
|
|
installation_date: string;
|
|
status: 'in_progress' | 'success' | 'failed';
|
|
output_log: string | null;
|
|
execution_mode: 'local' | 'ssh';
|
|
container_status?: 'running' | 'stopped' | 'unknown';
|
|
web_ui_ip: string | null;
|
|
web_ui_port: number | null;
|
|
}
|
|
|
|
interface ScriptInstallationCardProps {
|
|
script: InstalledScript;
|
|
isEditing: boolean;
|
|
editFormData: { script_name: string; container_id: string; web_ui_ip: string; web_ui_port: string };
|
|
onInputChange: (field: 'script_name' | 'container_id' | 'web_ui_ip' | 'web_ui_port', value: string) => void;
|
|
onEdit: () => void;
|
|
onSave: () => void;
|
|
onCancel: () => void;
|
|
onUpdate: () => void;
|
|
onShell: () => void;
|
|
onDelete: () => void;
|
|
isUpdating: boolean;
|
|
isDeleting: boolean;
|
|
// New container control props
|
|
containerStatus?: 'running' | 'stopped' | 'unknown';
|
|
onStartStop: (action: 'start' | 'stop') => void;
|
|
onDestroy: () => void;
|
|
isControlling: boolean;
|
|
// Web UI props
|
|
onOpenWebUI: () => void;
|
|
onAutoDetectWebUI: () => void;
|
|
isAutoDetecting: boolean;
|
|
}
|
|
|
|
export function ScriptInstallationCard({
|
|
script,
|
|
isEditing,
|
|
editFormData,
|
|
onInputChange,
|
|
onEdit,
|
|
onSave,
|
|
onCancel,
|
|
onUpdate,
|
|
onShell,
|
|
onDelete,
|
|
isUpdating,
|
|
isDeleting,
|
|
containerStatus,
|
|
onStartStop,
|
|
onDestroy,
|
|
isControlling,
|
|
onOpenWebUI,
|
|
onAutoDetectWebUI,
|
|
isAutoDetecting
|
|
}: ScriptInstallationCardProps) {
|
|
const formatDate = (dateString: string) => {
|
|
return new Date(dateString).toLocaleString();
|
|
};
|
|
|
|
// Helper function to check if a script has any actions available
|
|
const hasActions = (script: InstalledScript) => {
|
|
if (script.container_id && script.execution_mode === 'ssh') return true;
|
|
if (script.web_ui_ip != null) return true;
|
|
if (!script.container_id || script.execution_mode !== 'ssh') return true;
|
|
return false;
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className="bg-card border border-border rounded-lg p-4 shadow-sm hover:shadow-md transition-shadow"
|
|
style={{ borderLeft: `4px solid ${script.server_color ?? 'transparent'}` }}
|
|
>
|
|
{/* Header with Script Name and Status */}
|
|
<div className="flex items-start justify-between mb-3">
|
|
<div className="flex-1 min-w-0">
|
|
{isEditing ? (
|
|
<div className="space-y-2">
|
|
<input
|
|
type="text"
|
|
value={editFormData.script_name}
|
|
onChange={(e) => onInputChange('script_name', e.target.value)}
|
|
className="w-full px-2 py-1 text-sm border border-border rounded bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
|
placeholder="Script name"
|
|
/>
|
|
<div className="text-xs text-muted-foreground">{script.script_path}</div>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<div className="text-sm font-medium text-foreground truncate">{script.script_name}</div>
|
|
<div className="text-xs text-muted-foreground truncate">{script.script_path}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="ml-2 flex-shrink-0">
|
|
<StatusBadge status={script.status}>
|
|
{script.status.replace('_', ' ').toUpperCase()}
|
|
</StatusBadge>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Details Grid */}
|
|
<div className="grid grid-cols-1 gap-3 mb-4">
|
|
{/* Container ID */}
|
|
<div>
|
|
<div className="text-xs font-medium text-muted-foreground mb-1">Container ID</div>
|
|
{isEditing ? (
|
|
<input
|
|
type="text"
|
|
value={editFormData.container_id}
|
|
onChange={(e) => onInputChange('container_id', e.target.value)}
|
|
className="w-full px-2 py-1 text-sm font-mono border border-border rounded bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
|
placeholder="Container ID"
|
|
/>
|
|
) : (
|
|
<div className="text-sm font-mono text-foreground break-all">
|
|
{script.container_id ? (
|
|
<div className="flex items-center space-x-2">
|
|
<span>{script.container_id}</span>
|
|
{script.container_status && (
|
|
<div className="flex items-center space-x-1">
|
|
<div className={`w-2 h-2 rounded-full ${
|
|
script.container_status === 'running' ? 'bg-success' :
|
|
script.container_status === 'stopped' ? 'bg-error' :
|
|
'bg-muted-foreground'
|
|
}`}></div>
|
|
<span className={`text-xs font-medium ${
|
|
script.container_status === 'running' ? 'text-success' :
|
|
script.container_status === 'stopped' ? 'text-error' :
|
|
'text-muted-foreground'
|
|
}`}>
|
|
{script.container_status === 'running' ? 'Running' :
|
|
script.container_status === 'stopped' ? 'Stopped' :
|
|
'Unknown'}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : '-'}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Web UI */}
|
|
<div>
|
|
<div className="text-xs font-medium text-muted-foreground mb-1">IP:PORT</div>
|
|
{isEditing ? (
|
|
<div className="flex items-center space-x-2">
|
|
<input
|
|
type="text"
|
|
value={editFormData.web_ui_ip}
|
|
onChange={(e) => onInputChange('web_ui_ip', e.target.value)}
|
|
className="flex-1 px-2 py-1 text-sm font-mono border border-border rounded bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
|
placeholder="IP"
|
|
/>
|
|
<span className="text-muted-foreground">:</span>
|
|
<input
|
|
type="number"
|
|
value={editFormData.web_ui_port}
|
|
onChange={(e) => onInputChange('web_ui_port', e.target.value)}
|
|
className="w-20 px-2 py-1 text-sm font-mono border border-border rounded bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
|
placeholder="Port"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="text-sm font-mono text-foreground">
|
|
{script.web_ui_ip ? (
|
|
<div className="flex items-center justify-between w-full">
|
|
<button
|
|
onClick={onOpenWebUI}
|
|
disabled={containerStatus === 'stopped'}
|
|
className={`text-info hover:text-info/80 hover:underline flex-shrink-0 ${
|
|
containerStatus === 'stopped' ? 'opacity-50 cursor-not-allowed' : ''
|
|
}`}
|
|
>
|
|
{script.web_ui_ip}:{script.web_ui_port ?? 80}
|
|
</button>
|
|
{script.container_id && script.execution_mode === 'ssh' && (
|
|
<button
|
|
onClick={onAutoDetectWebUI}
|
|
disabled={isAutoDetecting}
|
|
className="text-xs px-2 py-1 bg-info hover:bg-info/90 text-info-foreground border border-info rounded disabled:opacity-50 transition-colors flex-shrink-0 ml-2"
|
|
title="Re-detect IP and port"
|
|
>
|
|
{isAutoDetecting ? '...' : 'Re-detect'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center space-x-2">
|
|
<span className="text-muted-foreground">-</span>
|
|
{script.container_id && script.execution_mode === 'ssh' && (
|
|
<button
|
|
onClick={onAutoDetectWebUI}
|
|
disabled={isAutoDetecting}
|
|
className="text-xs px-2 py-1 bg-info hover:bg-info/90 text-info-foreground border border-info rounded disabled:opacity-50 transition-colors"
|
|
title="Re-detect IP and port"
|
|
>
|
|
{isAutoDetecting ? '...' : 'Re-detect'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Server */}
|
|
<div>
|
|
<div className="text-xs font-medium text-muted-foreground mb-1">Server</div>
|
|
<span
|
|
className="text-sm px-3 py-1 rounded inline-block"
|
|
style={{
|
|
backgroundColor: script.server_color ?? 'transparent',
|
|
color: script.server_color ? getContrastColor(script.server_color) : 'inherit'
|
|
}}
|
|
>
|
|
{script.server_name ?? '-'}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Installation Date */}
|
|
<div>
|
|
<div className="text-xs font-medium text-muted-foreground mb-1">Installation Date</div>
|
|
<div className="text-sm text-muted-foreground">
|
|
{formatDate(String(script.installation_date))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Action Buttons */}
|
|
<div className="flex flex-wrap gap-2">
|
|
{isEditing ? (
|
|
<>
|
|
<Button
|
|
onClick={onSave}
|
|
disabled={isUpdating}
|
|
variant="save"
|
|
size="sm"
|
|
className="flex-1 min-w-0"
|
|
>
|
|
{isUpdating ? 'Saving...' : 'Save'}
|
|
</Button>
|
|
<Button
|
|
onClick={onCancel}
|
|
variant="cancel"
|
|
size="sm"
|
|
className="flex-1 min-w-0"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Button
|
|
onClick={onEdit}
|
|
variant="edit"
|
|
size="sm"
|
|
className="flex-1 min-w-0"
|
|
>
|
|
Edit
|
|
</Button>
|
|
{hasActions(script) && (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="flex-1 min-w-0 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"
|
|
>
|
|
Actions
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent className="w-48 bg-card border-border">
|
|
{script.container_id && (
|
|
<DropdownMenuItem
|
|
onClick={onUpdate}
|
|
disabled={containerStatus === 'stopped'}
|
|
className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20"
|
|
>
|
|
Update
|
|
</DropdownMenuItem>
|
|
)}
|
|
{script.container_id && script.execution_mode === 'ssh' && (
|
|
<DropdownMenuItem
|
|
onClick={onShell}
|
|
disabled={containerStatus === 'stopped'}
|
|
className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20"
|
|
>
|
|
Shell
|
|
</DropdownMenuItem>
|
|
)}
|
|
{script.web_ui_ip && (
|
|
<DropdownMenuItem
|
|
onClick={onOpenWebUI}
|
|
disabled={containerStatus === 'stopped'}
|
|
className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20"
|
|
>
|
|
Open UI
|
|
</DropdownMenuItem>
|
|
)}
|
|
{script.container_id && script.execution_mode === 'ssh' && (
|
|
<>
|
|
<DropdownMenuSeparator className="bg-border" />
|
|
<DropdownMenuItem
|
|
onClick={() => onStartStop(containerStatus === 'running' ? 'stop' : 'start')}
|
|
disabled={isControlling || containerStatus === 'unknown'}
|
|
className={containerStatus === 'running'
|
|
? "text-error hover:text-error-foreground hover:bg-error/20 focus:bg-error/20"
|
|
: "text-success hover:text-success-foreground hover:bg-success/20 focus:bg-success/20"
|
|
}
|
|
>
|
|
{isControlling ? 'Working...' : containerStatus === 'running' ? 'Stop' : 'Start'}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={onDestroy}
|
|
disabled={isControlling}
|
|
className="text-error hover:text-error-foreground hover:bg-error/20 focus:bg-error/20"
|
|
>
|
|
{isControlling ? 'Working...' : 'Destroy'}
|
|
</DropdownMenuItem>
|
|
</>
|
|
)}
|
|
{(!script.container_id || script.execution_mode !== 'ssh') && (
|
|
<>
|
|
<DropdownMenuSeparator className="bg-border" />
|
|
<DropdownMenuItem
|
|
onClick={onDelete}
|
|
disabled={isDeleting}
|
|
className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20"
|
|
>
|
|
{isDeleting ? 'Deleting...' : 'Delete'}
|
|
</DropdownMenuItem>
|
|
</>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|