Add dark mode support across UI (#33)

* Add dark mode support across UI

Introduces DarkModeProvider and DarkModeToggle components for theme management. Updates all major UI components and pages to support dark mode styling using Tailwind CSS dark variants, improving accessibility and user experience for users preferring dark themes.

* Improve dark mode initialization and modal UI (#32)

Adds a script to layout.tsx to set dark mode before hydration, preventing UI flicker. Refactors DarkModeProvider to initialize theme and dark state after mount. Updates ScriptDetailModal for improved readability, consistent styling, and better handling of script status, install methods, and notes.
This commit is contained in:
CanbiZ
2025-10-06 13:35:53 +02:00
committed by GitHub
parent b77554a7b5
commit ec20e0322a
13 changed files with 678 additions and 296 deletions

View File

@@ -0,0 +1,83 @@
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'light' | 'dark' | 'system';
interface DarkModeContextType {
theme: Theme;
setTheme: (theme: Theme) => void;
isDark: boolean;
}
const DarkModeContext = createContext<DarkModeContextType | undefined>(undefined);
export function DarkModeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<Theme>('system');
const [isDark, setIsDark] = useState(false);
const [mounted, setMounted] = useState(false);
// Initialize theme from localStorage after mount
useEffect(() => {
setMounted(true);
const stored = localStorage.getItem('theme') as Theme;
if (stored && ['light', 'dark', 'system'].includes(stored)) {
setThemeState(stored);
}
// Set initial isDark state based on current DOM state
const currentlyDark = document.documentElement.classList.contains('dark');
setIsDark(currentlyDark);
}, []);
// Update dark mode state and DOM when theme changes
useEffect(() => {
if (!mounted) return;
const updateDarkMode = () => {
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const shouldBeDark = theme === 'dark' || (theme === 'system' && systemDark);
setIsDark(shouldBeDark);
// Apply to document
if (shouldBeDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
};
updateDarkMode();
// Listen for system theme changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => {
if (theme === 'system') {
updateDarkMode();
}
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [theme, mounted]);
const setTheme = (newTheme: Theme) => {
setThemeState(newTheme);
localStorage.setItem('theme', newTheme);
};
return (
<DarkModeContext.Provider value={{ theme, setTheme, isDark }}>
{children}
</DarkModeContext.Provider>
);
}
export function useDarkMode() {
const context = useContext(DarkModeContext);
if (context === undefined) {
throw new Error('useDarkMode must be used within a DarkModeProvider');
}
return context;
}

View File

@@ -0,0 +1,66 @@
'use client';
import { useDarkMode } from './DarkModeProvider';
export function DarkModeToggle() {
const { theme, setTheme, isDark } = useDarkMode();
const toggleTheme = () => {
if (theme === 'light') {
setTheme('dark');
} else if (theme === 'dark') {
setTheme('system');
} else {
setTheme('light');
}
};
const getIcon = () => {
if (theme === 'light') {
return (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clipRule="evenodd" />
</svg>
);
} else if (theme === 'dark') {
return (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
</svg>
);
} else {
// System theme icon
return (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M3 5a2 2 0 012-2h10a2 2 0 012 2v8a2 2 0 01-2 2h-2.22l.123.489.804.804A1 1 0 0113 18H7a1 1 0 01-.707-1.707l.804-.804L7.22 15H5a2 2 0 01-2-2V5zm5.771 7l.159-1.591A3.001 3.001 0 0112 8a3 3 0 01-3.229 2.409L8.771 12z" clipRule="evenodd" />
</svg>
);
}
};
const getLabel = () => {
if (theme === 'light') return 'Light mode';
if (theme === 'dark') return 'Dark mode';
return 'System theme';
};
return (
<button
onClick={toggleTheme}
className={`
flex items-center justify-center
w-10 h-10 rounded-lg
transition-all duration-200
hover:scale-105 active:scale-95
${isDark
? 'bg-gray-800 text-yellow-400 hover:bg-gray-700 border border-gray-600'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 border border-gray-300'
}
`}
title={getLabel()}
aria-label={getLabel()}
>
{getIcon()}
</button>
);
}

View File

@@ -119,7 +119,7 @@ export function InstalledScriptsTab() {
case 'in_progress':
return `${baseClasses} bg-yellow-100 text-yellow-800`;
default:
return `${baseClasses} bg-gray-100 text-gray-800`;
return `${baseClasses} bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200`;
}
};
@@ -131,14 +131,14 @@ export function InstalledScriptsTab() {
case 'ssh':
return `${baseClasses} bg-purple-100 text-purple-800`;
default:
return `${baseClasses} bg-gray-100 text-gray-800`;
return `${baseClasses} bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200`;
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">Loading installed scripts...</div>
<div className="text-gray-500 dark:text-gray-400">Loading installed scripts...</div>
</div>
);
}
@@ -160,8 +160,8 @@ export function InstalledScriptsTab() {
)}
{/* Header with Stats */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Installed Scripts</h2>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-4">Installed Scripts</h2>
{stats && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
@@ -192,14 +192,14 @@ export function InstalledScriptsTab() {
placeholder="Search scripts, container IDs, or servers..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400"
/>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as 'all' | 'success' | 'failed' | 'in_progress')}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400"
>
<option value="all">All Status</option>
<option value="success">Success</option>
@@ -210,7 +210,7 @@ export function InstalledScriptsTab() {
<select
value={serverFilter}
onChange={(e) => setServerFilter(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400"
>
<option value="all">All Servers</option>
<option value="local">Local</option>
@@ -222,60 +222,57 @@ export function InstalledScriptsTab() {
</div>
{/* Scripts Table */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
{filteredScripts.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
{scripts.length === 0 ? 'No installed scripts found.' : 'No scripts match your filters.'}
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Script Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Container ID
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Server
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Mode
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Installation Date
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{filteredScripts.map((script) => (
<tr key={script.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{script.script_name}</div>
<div className="text-sm text-gray-500">{script.script_path}</div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">{script.script_name}</div>
<div className="text-sm text-gray-500 dark:text-gray-400">{script.script_path}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{script.container_id ? (
<span className="text-sm font-mono text-gray-900">{String(script.container_id)}</span>
<span className="text-sm font-mono text-gray-900 dark:text-gray-100">{String(script.container_id)}</span>
) : (
<span className="text-sm text-gray-400">-</span>
<span className="text-sm text-gray-400 dark:text-gray-500">-</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{script.execution_mode === 'local' ? (
<span className="text-sm text-gray-900">Local</span>
<span className="text-sm text-gray-900 dark:text-gray-100">Local</span>
) : (
<div>
<div className="text-sm font-medium text-gray-900">{script.server_name}</div>
<div className="text-sm text-gray-500">{script.server_ip}</div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">{script.server_name}</div>
<div className="text-sm text-gray-500 dark:text-gray-400">{script.server_ip}</div>
</div>
)}
</td>
@@ -289,7 +286,7 @@ export function InstalledScriptsTab() {
{String(script.status).replace('_', ' ').toUpperCase()}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{formatDate(String(script.installation_date))}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">

View File

@@ -44,8 +44,8 @@ export function ResyncButton() {
disabled={isResyncing}
className={`flex items-center space-x-2 px-4 py-2 rounded-lg font-medium transition-colors ${
isResyncing
? 'bg-gray-400 text-white cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700'
? 'bg-gray-400 dark:bg-gray-600 text-white cursor-not-allowed'
: 'bg-blue-600 dark:bg-blue-700 text-white hover:bg-blue-700 dark:hover:bg-blue-600'
}`}
>
{isResyncing ? (
@@ -64,7 +64,7 @@ export function ResyncButton() {
</button>
{lastSync && (
<div className="text-sm text-gray-500">
<div className="text-sm text-gray-500 dark:text-gray-400">
Last sync: {lastSync.toLocaleTimeString()}
</div>
)}
@@ -72,8 +72,8 @@ export function ResyncButton() {
{syncMessage && (
<div className={`text-sm px-3 py-1 rounded-lg ${
syncMessage.includes('Error') || syncMessage.includes('Failed')
? 'bg-red-100 text-red-700'
: 'bg-green-100 text-green-700'
? 'bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300'
: 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300'
}`}>
{syncMessage}
</div>

View File

@@ -18,7 +18,7 @@ export function ScriptCard({ script, onClick }: ScriptCardProps) {
return (
<div
className="bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer border border-gray-200 hover:border-blue-300 h-full flex flex-col"
className="bg-white dark:bg-gray-800 rounded-lg shadow-md hover:shadow-lg dark:hover:shadow-xl transition-shadow duration-200 cursor-pointer border border-gray-200 dark:border-gray-700 hover:border-blue-300 dark:hover:border-blue-600 h-full flex flex-col"
onClick={() => onClick(script)}
>
<div className="p-6 flex-1 flex flex-col">
@@ -35,15 +35,15 @@ export function ScriptCard({ script, onClick }: ScriptCardProps) {
onError={handleImageError}
/>
) : (
<div className="w-12 h-12 bg-gray-200 rounded-lg flex items-center justify-center">
<span className="text-gray-500 text-lg font-semibold">
<div className="w-12 h-12 bg-gray-200 dark:bg-gray-700 rounded-lg flex items-center justify-center">
<span className="text-gray-500 dark:text-gray-400 text-lg font-semibold">
{script.name?.charAt(0)?.toUpperCase() || '?'}
</span>
</div>
)}
</div>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-gray-900 truncate">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 truncate">
{script.name || 'Unnamed Script'}
</h3>
<div className="mt-2 space-y-2">
@@ -51,15 +51,15 @@ export function ScriptCard({ script, onClick }: ScriptCardProps) {
<div className="flex items-center space-x-2">
<span className={`inline-flex items-center px-2 py-1 rounded text-xs font-medium ${
script.type === 'ct'
? 'bg-blue-100 text-blue-800'
? 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200'
: script.type === 'addon'
? 'bg-purple-100 text-purple-800'
: 'bg-gray-100 text-gray-800'
? 'bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200'
: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200'
}`}>
{script.type?.toUpperCase() || 'UNKNOWN'}
</span>
{script.updateable && (
<span className="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-amber-100 text-amber-800">
<span className="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-amber-100 dark:bg-amber-900 text-amber-800 dark:text-amber-200">
Updateable
</span>
)}
@@ -71,7 +71,7 @@ export function ScriptCard({ script, onClick }: ScriptCardProps) {
script.isDownloaded ? 'bg-green-500' : 'bg-red-500'
}`}></div>
<span className={`text-xs font-medium ${
script.isDownloaded ? 'text-green-700' : 'text-red-700'
script.isDownloaded ? 'text-green-700 dark:text-green-300' : 'text-red-700 dark:text-red-300'
}`}>
{script.isDownloaded ? 'Downloaded' : 'Not Downloaded'}
</span>
@@ -81,7 +81,7 @@ export function ScriptCard({ script, onClick }: ScriptCardProps) {
</div>
{/* Description */}
<p className="text-gray-600 text-sm line-clamp-3 mb-4 flex-1">
<p className="text-gray-600 dark:text-gray-300 text-sm line-clamp-3 mb-4 flex-1">
{script.description || 'No description available'}
</p>
@@ -92,7 +92,7 @@ export function ScriptCard({ script, onClick }: ScriptCardProps) {
href={script.website}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 text-sm font-medium flex items-center space-x-1"
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 text-sm font-medium flex items-center space-x-1"
onClick={(e) => e.stopPropagation()}
>
<span>Website</span>

View File

@@ -1,21 +1,31 @@
'use client';
"use client";
import { useState } from 'react';
import Image from 'next/image';
import { api } from '~/trpc/react';
import type { Script } from '~/types/script';
import { DiffViewer } from './DiffViewer';
import { TextViewer } from './TextViewer';
import { ExecutionModeModal } from './ExecutionModeModal';
import { useState } from "react";
import Image from "next/image";
import { api } from "~/trpc/react";
import type { Script } from "~/types/script";
import { DiffViewer } from "./DiffViewer";
import { TextViewer } from "./TextViewer";
import { ExecutionModeModal } from "./ExecutionModeModal";
interface ScriptDetailModalProps {
script: Script | null;
isOpen: boolean;
onClose: () => void;
onInstallScript?: (scriptPath: string, scriptName: string, mode?: 'local' | 'ssh', server?: any) => void;
onInstallScript?: (
scriptPath: string,
scriptName: string,
mode?: "local" | "ssh",
server?: any,
) => void;
}
export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }: ScriptDetailModalProps) {
export function ScriptDetailModal({
script,
isOpen,
onClose,
onInstallScript,
}: ScriptDetailModalProps) {
const [imageError, setImageError] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [loadMessage, setLoadMessage] = useState<string | null>(null);
@@ -25,15 +35,23 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
const [executionModeOpen, setExecutionModeOpen] = useState(false);
// Check if script files exist locally
const { data: scriptFilesData, refetch: refetchScriptFiles, isLoading: scriptFilesLoading } = api.scripts.checkScriptFiles.useQuery(
{ slug: script?.slug ?? '' },
{ enabled: !!script && isOpen }
const {
data: scriptFilesData,
refetch: refetchScriptFiles,
isLoading: scriptFilesLoading,
} = api.scripts.checkScriptFiles.useQuery(
{ slug: script?.slug ?? "" },
{ enabled: !!script && isOpen },
);
// Compare local and remote script content (run in parallel, not dependent on scriptFilesData)
const { data: comparisonData, refetch: refetchComparison, isLoading: comparisonLoading } = api.scripts.compareScriptContent.useQuery(
{ slug: script?.slug ?? '' },
{ enabled: !!script && isOpen }
const {
data: comparisonData,
refetch: refetchComparison,
isLoading: comparisonLoading,
} = api.scripts.compareScriptContent.useQuery(
{ slug: script?.slug ?? "" },
{ enabled: !!script && isOpen },
);
// Load script mutation
@@ -41,13 +59,14 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
onSuccess: (data) => {
setIsLoading(false);
if (data.success) {
const message = 'message' in data ? data.message : 'Script loaded successfully';
const message =
"message" in data ? data.message : "Script loaded successfully";
setLoadMessage(`${message}`);
// Refetch script files status and comparison data to update the UI
void refetchScriptFiles();
void refetchComparison();
} else {
const error = 'error' in data ? data.error : 'Failed to load script';
const error = "error" in data ? data.error : "Failed to load script";
setLoadMessage(`${error}`);
}
// Clear message after 5 seconds
@@ -74,7 +93,7 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
const handleLoadScript = async () => {
if (!script) return;
setIsLoading(true);
setLoadMessage(null);
loadScriptMutation.mutate({ slug: script.slug });
@@ -85,38 +104,39 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
setExecutionModeOpen(true);
};
const handleExecuteScript = (mode: 'local' | 'ssh', server?: any) => {
const handleExecuteScript = (mode: "local" | "ssh", server?: any) => {
if (!script || !onInstallScript) return;
// Find the script path (CT or tools)
const scriptMethod = script.install_methods?.find(method => method.script);
const scriptMethod = script.install_methods?.find(
(method) => method.script,
);
if (scriptMethod?.script) {
const scriptPath = `scripts/${scriptMethod.script}`;
const scriptName = script.name;
// Pass execution mode and server info to the parent
onInstallScript(scriptPath, scriptName, mode, server);
// Scroll to top of the page to see the terminal
window.scrollTo({ top: 0, behavior: 'smooth' });
window.scrollTo({ top: 0, behavior: "smooth" });
onClose(); // Close the modal when starting installation
}
};
const handleViewScript = () => {
setTextViewerOpen(true);
};
return (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
className="bg-opacity-50 fixed inset-0 z-50 flex items-center justify-center bg-black p-4"
onClick={handleBackdropClick}
>
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div className="max-h-[90vh] w-full max-w-4xl overflow-y-auto rounded-lg bg-white shadow-xl dark:bg-gray-800">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<div className="flex items-center justify-between border-b border-gray-200 p-6 dark:border-gray-700">
<div className="flex items-center space-x-4">
{script.logo && !imageError ? (
<Image
@@ -124,33 +144,37 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
alt={`${script.name} logo`}
width={64}
height={64}
className="w-16 h-16 rounded-lg object-contain"
className="h-16 w-16 rounded-lg object-contain"
onError={handleImageError}
/>
) : (
<div className="w-16 h-16 bg-gray-200 rounded-lg flex items-center justify-center">
<span className="text-gray-500 text-2xl font-semibold">
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-gray-200 dark:bg-gray-700">
<span className="text-2xl font-semibold text-gray-500 dark:text-gray-400">
{script.name.charAt(0).toUpperCase()}
</span>
</div>
)}
<div>
<h2 className="text-2xl font-bold text-gray-900">{script.name}</h2>
<div className="flex items-center space-x-2 mt-1">
<span className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${
script.type === 'ct'
? 'bg-blue-100 text-blue-800'
: 'bg-gray-100 text-gray-800'
}`}>
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{script.name}
</h2>
<div className="mt-1 flex items-center space-x-2">
<span
className={`inline-flex items-center rounded-full px-3 py-1 text-sm font-medium ${
script.type === "ct"
? "bg-blue-100 text-blue-800"
: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200"
}`}
>
{script.type.toUpperCase()}
</span>
{script.updateable && (
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800">
<span className="inline-flex items-center rounded-full bg-green-100 px-3 py-1 text-sm font-medium text-green-800">
Updateable
</span>
)}
{script.privileged && (
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-red-100 text-red-800">
<span className="inline-flex items-center rounded-full bg-red-100 px-3 py-1 text-sm font-medium text-red-800">
Privileged
</span>
)}
@@ -159,59 +183,100 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
</div>
<div className="flex items-center space-x-4">
{/* Install Button - only show if script files exist */}
{scriptFilesData?.success && scriptFilesData.ctExists && onInstallScript && (
<button
onClick={handleInstallScript}
className="flex items-center space-x-2 px-4 py-2 rounded-lg font-medium transition-colors bg-blue-600 text-white hover:bg-blue-700"
>
<svg className="w-4 h-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>
)}
{scriptFilesData?.success &&
scriptFilesData.ctExists &&
onInstallScript && (
<button
onClick={handleInstallScript}
className="flex items-center space-x-2 rounded-lg bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700"
>
<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>
)}
{/* View Button - only show if script files exist */}
{scriptFilesData?.success && (scriptFilesData.ctExists || scriptFilesData.installExists) && (
<button
onClick={handleViewScript}
className="flex items-center space-x-2 px-4 py-2 rounded-lg font-medium transition-colors bg-purple-600 text-white hover:bg-purple-700"
>
<svg className="w-4 h-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>
)}
{scriptFilesData?.success &&
(scriptFilesData.ctExists || scriptFilesData.installExists) && (
<button
onClick={handleViewScript}
className="flex items-center space-x-2 rounded-lg bg-purple-600 px-4 py-2 font-medium text-white transition-colors hover:bg-purple-700"
>
<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>
)}
{/* Load/Update Script Button */}
{(() => {
const hasLocalFiles = scriptFilesData?.success && (scriptFilesData.ctExists || scriptFilesData.installExists);
const hasDifferences = comparisonData?.success && comparisonData.hasDifferences;
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 px-4 py-2 rounded-lg font-medium transition-colors ${
className={`flex items-center space-x-2 rounded-lg px-4 py-2 font-medium transition-colors ${
isLoading
? 'bg-gray-400 text-white cursor-not-allowed'
: 'bg-green-600 text-white hover:bg-green-700'
? "cursor-not-allowed bg-gray-400 text-white"
: "bg-green-600 text-white hover:bg-green-700"
}`}
>
{isLoading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-white"></div>
<span>Loading...</span>
</>
) : (
<>
<svg className="w-4 h-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
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>
</>
@@ -223,10 +288,20 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
return (
<button
disabled
className="flex items-center space-x-2 px-4 py-2 rounded-lg font-medium transition-colors bg-gray-400 text-white cursor-not-allowed"
className="flex cursor-not-allowed items-center space-x-2 rounded-lg bg-gray-400 px-4 py-2 font-medium text-white transition-colors"
>
<svg className="w-4 h-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
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>
@@ -237,21 +312,31 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
<button
onClick={handleLoadScript}
disabled={isLoading}
className={`flex items-center space-x-2 px-4 py-2 rounded-lg font-medium transition-colors ${
className={`flex items-center space-x-2 rounded-lg px-4 py-2 font-medium transition-colors ${
isLoading
? 'bg-gray-400 text-white cursor-not-allowed'
: 'bg-orange-600 text-white hover:bg-orange-700'
? "cursor-not-allowed bg-gray-400 text-white"
: "bg-orange-600 text-white hover:bg-orange-700"
}`}
>
{isLoading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-white"></div>
<span>Updating...</span>
</>
) : (
<>
<svg className="w-4 h-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
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>
</>
@@ -262,10 +347,20 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
})()}
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
className="text-gray-400 transition-colors hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
>
<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>
@@ -273,114 +368,169 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
{/* Load Message */}
{loadMessage && (
<div className="mx-6 mb-4 p-3 rounded-lg bg-blue-50 text-blue-800 text-sm">
<div className="mx-6 mb-4 rounded-lg bg-blue-50 p-3 text-sm text-blue-800">
{loadMessage}
</div>
)}
{/* Script Files Status */}
{(scriptFilesLoading || comparisonLoading) && (
<div className="mx-6 mb-4 p-3 rounded-lg bg-blue-50 text-sm">
<div className="mx-6 mb-4 rounded-lg bg-blue-50 p-3 text-sm">
<div className="flex items-center space-x-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-blue-600"></div>
<span>Loading script status...</span>
</div>
</div>
)}
{scriptFilesData?.success && !scriptFilesLoading && (() => {
// Determine script type from the first install method
const firstScript = script?.install_methods?.[0]?.script;
let scriptType = 'Script';
if (firstScript?.startsWith('ct/')) {
scriptType = 'CT Script';
} else if (firstScript?.startsWith('tools/')) {
scriptType = 'Tools Script';
} else if (firstScript?.startsWith('vm/')) {
scriptType = 'VM Script';
} else if (firstScript?.startsWith('vw/')) {
scriptType = 'VW Script';
}
return (
<div className="mx-6 mb-4 p-3 rounded-lg bg-gray-50 text-sm">
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<div className={`w-2 h-2 rounded-full ${scriptFilesData.ctExists ? 'bg-green-500' : 'bg-gray-300'}`}></div>
<span>{scriptType}: {scriptFilesData.ctExists ? 'Available' : 'Not loaded'}</span>
</div>
<div className="flex items-center space-x-2">
<div className={`w-2 h-2 rounded-full ${scriptFilesData.installExists ? 'bg-green-500' : 'bg-gray-300'}`}></div>
<span>Install Script: {scriptFilesData.installExists ? 'Available' : 'Not loaded'}</span>
</div>
{scriptFilesData?.success && (scriptFilesData.ctExists || scriptFilesData.installExists) && comparisonData?.success && !comparisonLoading && (
{scriptFilesData?.success &&
!scriptFilesLoading &&
(() => {
// Determine script type from the first install method
const firstScript = script?.install_methods?.[0]?.script;
let scriptType = "Script";
if (firstScript?.startsWith("ct/")) {
scriptType = "CT Script";
} else if (firstScript?.startsWith("tools/")) {
scriptType = "Tools Script";
} else if (firstScript?.startsWith("vm/")) {
scriptType = "VM Script";
} else if (firstScript?.startsWith("vw/")) {
scriptType = "VW Script";
}
return (
<div className="mx-6 mb-4 rounded-lg bg-gray-50 p-3 text-sm text-gray-700 dark:bg-gray-700 dark:text-gray-300">
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<div className={`w-2 h-2 rounded-full ${comparisonData.hasDifferences ? 'bg-orange-500' : 'bg-green-500'}`}></div>
<span>Status: {comparisonData.hasDifferences ? 'Update available' : 'Up to date'}</span>
<div
className={`h-2 w-2 rounded-full ${scriptFilesData.ctExists ? "bg-green-500" : "bg-gray-300"}`}
></div>
<span>
{scriptType}:{" "}
{scriptFilesData.ctExists ? "Available" : "Not loaded"}
</span>
</div>
<div className="flex items-center space-x-2">
<div
className={`h-2 w-2 rounded-full ${scriptFilesData.installExists ? "bg-green-500" : "bg-gray-300"}`}
></div>
<span>
Install Script:{" "}
{scriptFilesData.installExists
? "Available"
: "Not loaded"}
</span>
</div>
{scriptFilesData?.success &&
(scriptFilesData.ctExists ||
scriptFilesData.installExists) &&
comparisonData?.success &&
!comparisonLoading && (
<div className="flex items-center space-x-2">
<div
className={`h-2 w-2 rounded-full ${comparisonData.hasDifferences ? "bg-orange-500" : "bg-green-500"}`}
></div>
<span>
Status:{" "}
{comparisonData.hasDifferences
? "Update available"
: "Up to date"}
</span>
</div>
)}
</div>
{scriptFilesData.files.length > 0 && (
<div className="mt-2 text-xs text-gray-600 dark:text-gray-400">
Files: {scriptFilesData.files.join(", ")}
</div>
)}
</div>
{scriptFilesData.files.length > 0 && (
<div className="mt-2 text-xs text-gray-600">
Files: {scriptFilesData.files.join(', ')}
</div>
)}
</div>
);
})()}
);
})()}
{/* Content */}
<div className="p-6 space-y-6">
<div className="space-y-6 p-6">
{/* Description */}
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">Description</h3>
<p className="text-gray-600">{script.description}</p>
<h3 className="mb-2 text-lg font-semibold text-gray-900 dark:text-gray-100">
Description
</h3>
<p className="text-gray-600 dark:text-gray-300">
{script.description}
</p>
</div>
{/* Basic Information */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-3">Basic Information</h3>
<h3 className="mb-3 text-lg font-semibold text-gray-900 dark:text-gray-100">
Basic Information
</h3>
<dl className="space-y-2">
<div>
<dt className="text-sm font-medium text-gray-500">Slug</dt>
<dd className="text-sm text-gray-900 font-mono">{script.slug}</dd>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
Slug
</dt>
<dd className="font-mono text-sm text-gray-900 dark:text-gray-100">
{script.slug}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Date Created</dt>
<dd className="text-sm text-gray-900">{script.date_created}</dd>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
Date Created
</dt>
<dd className="text-sm text-gray-900 dark:text-gray-100">
{script.date_created}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Categories</dt>
<dd className="text-sm text-gray-900">{script.categories.join(', ')}</dd>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
Categories
</dt>
<dd className="text-sm text-gray-900 dark:text-gray-100">
{script.categories.join(", ")}
</dd>
</div>
{script.interface_port && (
<div>
<dt className="text-sm font-medium text-gray-500">Interface Port</dt>
<dd className="text-sm text-gray-900">{script.interface_port}</dd>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
Interface Port
</dt>
<dd className="text-sm text-gray-900 dark:text-gray-100">
{script.interface_port}
</dd>
</div>
)}
{script.config_path && (
<div>
<dt className="text-sm font-medium text-gray-500">Config Path</dt>
<dd className="text-sm text-gray-900 font-mono">{script.config_path}</dd>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
Config Path
</dt>
<dd className="font-mono text-sm text-gray-900 dark:text-gray-100">
{script.config_path}
</dd>
</div>
)}
</dl>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-3">Links</h3>
<h3 className="mb-3 text-lg font-semibold text-gray-900 dark:text-gray-100">
Links
</h3>
<dl className="space-y-2">
{script.website && (
<div>
<dt className="text-sm font-medium text-gray-500">Website</dt>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
Website
</dt>
<dd className="text-sm">
<a
href={script.website}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 break-all"
className="break-all text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
>
{script.website}
</a>
@@ -389,13 +539,15 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
)}
{script.documentation && (
<div>
<dt className="text-sm font-medium text-gray-500">Documentation</dt>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
Documentation
</dt>
<dd className="text-sm">
<a
href={script.documentation}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 break-all"
className="break-all text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
>
{script.documentation}
</a>
@@ -406,56 +558,94 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
</div>
</div>
{/* Install Methods */}
{script.install_methods.length > 0 && (
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-3">Install Methods</h3>
<div className="space-y-4">
{script.install_methods.map((method, index) => (
<div key={index} className="border border-gray-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h4 className="font-medium text-gray-900 capitalize">{method.type}</h4>
<span className="text-sm text-gray-500 font-mono">{method.script}</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<dt className="font-medium text-gray-500">CPU</dt>
<dd className="text-gray-900">{method.resources.cpu} cores</dd>
{/* Install Methods - Hide for PVE and ADDON types as they typically don't have install methods */}
{script.install_methods.length > 0 &&
script.type !== "pve" &&
script.type !== "addon" && (
<div>
<h3 className="mb-3 text-lg font-semibold text-gray-900 dark:text-gray-100">
Install Methods
</h3>
<div className="space-y-4">
{script.install_methods.map((method, index) => (
<div
key={index}
className="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-600 dark:bg-gray-700"
>
<div className="mb-3 flex items-center justify-between">
<h4 className="font-medium text-gray-900 capitalize dark:text-gray-100">
{method.type}
</h4>
<span className="font-mono text-sm text-gray-500 dark:text-gray-400">
{method.script}
</span>
</div>
<div>
<dt className="font-medium text-gray-500">RAM</dt>
<dd className="text-gray-900">{method.resources.ram} MB</dd>
</div>
<div>
<dt className="font-medium text-gray-500">HDD</dt>
<dd className="text-gray-900">{method.resources.hdd} GB</dd>
</div>
<div>
<dt className="font-medium text-gray-500">OS</dt>
<dd className="text-gray-900">{method.resources.os} {method.resources.version}</dd>
<div className="grid grid-cols-2 gap-4 text-sm md:grid-cols-4">
<div>
<dt className="font-medium text-gray-500 dark:text-gray-400">
CPU
</dt>
<dd className="text-gray-900 dark:text-gray-100">
{method.resources.cpu} cores
</dd>
</div>
<div>
<dt className="font-medium text-gray-500 dark:text-gray-400">
RAM
</dt>
<dd className="text-gray-900 dark:text-gray-100">
{method.resources.ram} MB
</dd>
</div>
<div>
<dt className="font-medium text-gray-500 dark:text-gray-400">
HDD
</dt>
<dd className="text-gray-900 dark:text-gray-100">
{method.resources.hdd} GB
</dd>
</div>
<div>
<dt className="font-medium text-gray-500 dark:text-gray-400">
OS
</dt>
<dd className="text-gray-900 dark:text-gray-100">
{method.resources.os} {method.resources.version}
</dd>
</div>
</div>
</div>
</div>
))}
))}
</div>
</div>
</div>
)}
)}
{/* Default Credentials */}
{(script.default_credentials.username ?? script.default_credentials.password) && (
{(script.default_credentials.username ??
script.default_credentials.password) && (
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-3">Default Credentials</h3>
<h3 className="mb-3 text-lg font-semibold text-gray-900">
Default Credentials
</h3>
<dl className="space-y-2">
{script.default_credentials.username && (
<div>
<dt className="text-sm font-medium text-gray-500">Username</dt>
<dd className="text-sm text-gray-900 font-mono">{script.default_credentials.username}</dd>
<dt className="text-sm font-medium text-gray-500">
Username
</dt>
<dd className="font-mono text-sm text-gray-900">
{script.default_credentials.username}
</dd>
</div>
)}
{script.default_credentials.password && (
<div>
<dt className="text-sm font-medium text-gray-500">Password</dt>
<dd className="text-sm text-gray-900 font-mono">{script.default_credentials.password}</dd>
<dt className="text-sm font-medium text-gray-500">
Password
</dt>
<dd className="font-mono text-sm text-gray-900">
{script.default_credentials.password}
</dd>
</div>
)}
</dl>
@@ -465,29 +655,37 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
{/* Notes */}
{script.notes.length > 0 && (
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-3">Notes</h3>
<h3 className="mb-3 text-lg font-semibold text-gray-900 dark:text-gray-100">
Notes
</h3>
<ul className="space-y-2">
{script.notes.map((note, index) => {
// Handle both object and string note formats
const noteText = typeof note === 'string' ? note : note.text;
const noteType = typeof note === 'string' ? 'info' : note.type;
const noteText = typeof note === "string" ? note : note.text;
const noteType =
typeof note === "string" ? "info" : note.type;
return (
<li key={index} className={`text-sm p-3 rounded-lg ${
noteType === 'warning'
? 'bg-yellow-50 text-yellow-800 border-l-4 border-yellow-400'
: noteType === 'error'
? 'bg-red-50 text-red-800 border-l-4 border-red-400'
: 'bg-gray-50 text-gray-600'
}`}>
<li
key={index}
className={`rounded-lg p-3 text-sm ${
noteType === "warning"
? "border-l-4 border-yellow-400 bg-yellow-50 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-200"
: noteType === "error"
? "border-l-4 border-red-400 bg-red-50 text-red-800 dark:bg-red-900/20 dark:text-red-200"
: "bg-gray-50 text-gray-600 dark:bg-gray-700 dark:text-gray-300"
}`}
>
<div className="flex items-start">
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium mr-2 ${
noteType === 'warning'
? 'bg-yellow-100 text-yellow-800'
: noteType === 'error'
? 'bg-red-100 text-red-800'
: 'bg-blue-100 text-blue-800'
}`}>
<span
className={`mr-2 inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
noteType === "warning"
? "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200"
: noteType === "error"
? "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
}`}
>
{noteType}
</span>
<span>{noteText}</span>
@@ -517,7 +715,12 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
{/* Text Viewer Modal */}
{script && (
<TextViewer
scriptName={script.install_methods?.find(method => method.script?.startsWith('ct/'))?.script?.split('/').pop() ?? `${script.slug}.sh`}
scriptName={
script.install_methods
?.find((method) => method.script?.startsWith("ct/"))
?.script?.split("/")
.pop() ?? `${script.slug}.sh`
}
isOpen={textViewerOpen}
onClose={() => setTextViewerOpen(false)}
/>

View File

@@ -155,7 +155,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
<div className="mb-8">
<div className="relative max-w-md mx-auto">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg className="h-5 w-5 text-gray-400 dark:text-gray-500" 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>
</div>
@@ -164,12 +164,12 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
placeholder="Search scripts by name..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-lg leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
className="block w-full pl-10 pr-3 py-3 border border-gray-300 dark:border-gray-600 rounded-lg leading-5 bg-white dark:bg-gray-800 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-gray-100 focus:outline-none focus:placeholder-gray-400 dark:focus:placeholder-gray-300 focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 text-sm"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600"
className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
>
<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" />

View File

@@ -75,7 +75,7 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
<label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Server Name *
</label>
<input
@@ -83,16 +83,16 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
id="name"
value={formData.name}
onChange={handleChange('name')}
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
errors.name ? 'border-red-300' : 'border-gray-300'
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 ${
errors.name ? 'border-red-300 dark:border-red-600' : 'border-gray-300 dark:border-gray-600'
}`}
placeholder="e.g., Production Server"
/>
{errors.name && <p className="mt-1 text-sm text-red-600">{errors.name}</p>}
{errors.name && <p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.name}</p>}
</div>
<div>
<label htmlFor="ip" className="block text-sm font-medium text-gray-700 mb-1">
<label htmlFor="ip" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
IP Address *
</label>
<input
@@ -100,16 +100,16 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
id="ip"
value={formData.ip}
onChange={handleChange('ip')}
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
errors.ip ? 'border-red-300' : 'border-gray-300'
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 ${
errors.ip ? 'border-red-300 dark:border-red-600' : 'border-gray-300 dark:border-gray-600'
}`}
placeholder="e.g., 192.168.1.100"
/>
{errors.ip && <p className="mt-1 text-sm text-red-600">{errors.ip}</p>}
{errors.ip && <p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.ip}</p>}
</div>
<div>
<label htmlFor="user" className="block text-sm font-medium text-gray-700 mb-1">
<label htmlFor="user" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Username *
</label>
<input
@@ -117,16 +117,16 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
id="user"
value={formData.user}
onChange={handleChange('user')}
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
errors.user ? 'border-red-300' : 'border-gray-300'
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 ${
errors.user ? 'border-red-300 dark:border-red-600' : 'border-gray-300 dark:border-gray-600'
}`}
placeholder="e.g., root"
/>
{errors.user && <p className="mt-1 text-sm text-red-600">{errors.user}</p>}
{errors.user && <p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.user}</p>}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Password *
</label>
<input
@@ -134,12 +134,12 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
id="password"
value={formData.password}
onChange={handleChange('password')}
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
errors.password ? 'border-red-300' : 'border-gray-300'
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 ${
errors.password ? 'border-red-300 dark:border-red-600' : 'border-gray-300 dark:border-gray-600'
}`}
placeholder="Enter password"
/>
{errors.password && <p className="mt-1 text-sm text-red-600">{errors.password}</p>}
{errors.password && <p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.password}</p>}
</div>
</div>
@@ -148,14 +148,14 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-800 focus:ring-blue-500 dark:focus:ring-blue-400"
>
Cancel
</button>
)}
<button
type="submit"
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 dark:bg-blue-700 border border-transparent rounded-md hover:bg-blue-700 dark:hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-800 focus:ring-blue-500 dark:focus:ring-blue-400"
>
{isEditing ? 'Update Server' : 'Add Server'}
</button>

View File

@@ -10,7 +10,7 @@ export function SettingsButton() {
<>
<button
onClick={() => setIsOpen(true)}
className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors duration-200"
className="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-800 focus:ring-blue-500 dark:focus:ring-blue-400 transition-colors duration-200"
title="Add PVE Server"
>
<svg

View File

@@ -98,14 +98,14 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-hidden">
<div className="fixed inset-0 bg-black bg-opacity-50 dark:bg-black dark:bg-opacity-70 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<h2 className="text-2xl font-bold text-gray-900">Settings</h2>
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Settings</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
className="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 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" />
@@ -120,8 +120,8 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
onClick={() => setActiveTab('servers')}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === 'servers'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
Server Settings
@@ -130,8 +130,8 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
onClick={() => setActiveTab('general')}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === 'general'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
General
@@ -142,10 +142,10 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
{/* Content */}
<div className="p-6 overflow-y-auto max-h-[calc(90vh-200px)]">
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-md">
<div className="mb-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<svg className="h-5 w-5 text-red-400 dark:text-red-300" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
@@ -160,14 +160,14 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
{activeTab === 'servers' && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium text-gray-900 mb-4">Server Configurations</h3>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">Server Configurations</h3>
<ServerForm onSubmit={handleCreateServer} />
</div>
<div>
<h3 className="text-lg font-medium text-gray-900 mb-4">Saved Servers</h3>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">Saved Servers</h3>
{loading ? (
<div className="text-center py-8">
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<p className="mt-2 text-gray-600">Loading servers...</p>
</div>
@@ -184,8 +184,8 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
{activeTab === 'general' && (
<div>
<h3 className="text-lg font-medium text-gray-900 mb-4">General Settings</h3>
<p className="text-gray-600">General settings will be available in a future update.</p>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">General Settings</h3>
<p className="text-gray-600 dark:text-gray-300">General settings will be available in a future update.</p>
</div>
)}
</div>

View File

@@ -4,6 +4,8 @@ import { type Metadata } from "next";
import { Geist } from "next/font/google";
import { TRPCReactProvider } from "~/trpc/react";
import { DarkModeProvider } from "./_components/DarkModeProvider";
import { DarkModeToggle } from "./_components/DarkModeToggle";
export const metadata: Metadata = {
title: "PVE Scripts local",
@@ -25,8 +27,37 @@ export default function RootLayout({
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en" className={`${geist.variable}`}>
<body>
<TRPCReactProvider>{children}</TRPCReactProvider>
<head>
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
try {
const stored = localStorage.getItem('theme');
const theme = stored && ['light', 'dark', 'system'].includes(stored) ? stored : 'system';
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const shouldBeDark = theme === 'dark' || (theme === 'system' && systemDark);
if (shouldBeDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
} catch (e) {}
})();
`,
}}
/>
</head>
<body className="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 transition-colors">
<DarkModeProvider>
{/* Dark Mode Toggle in top right corner */}
<div className="fixed top-4 right-4 z-50">
<DarkModeToggle />
</div>
<TRPCReactProvider>{children}</TRPCReactProvider>
</DarkModeProvider>
</body>
</html>
);

View File

@@ -21,14 +21,14 @@ export default function Home() {
};
return (
<main className="min-h-screen bg-gray-100">
<main className="min-h-screen bg-gray-100 dark:bg-gray-900">
<div className="container mx-auto px-4 py-8">
{/* Header */}
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-gray-800 mb-2">
<h1 className="text-4xl font-bold text-gray-800 dark:text-gray-100 mb-2">
🚀 PVE Scripts Management
</h1>
<p className="text-gray-600">
<p className="text-gray-600 dark:text-gray-300">
Manage and execute Proxmox helper scripts locally with live output streaming
</p>
</div>
@@ -43,14 +43,14 @@ export default function Home() {
{/* Tab Navigation */}
<div className="mb-8">
<div className="border-b border-gray-200">
<div className="border-b border-gray-200 dark:border-gray-700">
<nav className="-mb-px flex space-x-8">
<button
onClick={() => setActiveTab('scripts')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'scripts'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
📦 Available Scripts
@@ -59,8 +59,8 @@ export default function Home() {
onClick={() => setActiveTab('installed')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'installed'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
🗂 Installed Scripts

View File

@@ -5,6 +5,8 @@
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
@variant dark (&:is(.dark, .dark *));
/* Terminal-specific styles for ANSI escape code rendering */
.terminal-output {
font-family: 'Courier New', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;