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

View File

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

View File

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

View File

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

View File

@@ -155,7 +155,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
<div className="mb-8"> <div className="mb-8">
<div className="relative max-w-md mx-auto"> <div className="relative max-w-md mx-auto">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg> </svg>
</div> </div>
@@ -164,12 +164,12 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
placeholder="Search scripts by name..." placeholder="Search scripts by name..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} 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 && ( {searchQuery && (
<button <button
onClick={() => setSearchQuery('')} 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"> <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" /> <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"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <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 * Server Name *
</label> </label>
<input <input
@@ -83,16 +83,16 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
id="name" id="name"
value={formData.name} value={formData.name}
onChange={handleChange('name')} onChange={handleChange('name')}
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${ 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' : 'border-gray-300' errors.name ? 'border-red-300 dark:border-red-600' : 'border-gray-300 dark:border-gray-600'
}`} }`}
placeholder="e.g., Production Server" 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>
<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 * IP Address *
</label> </label>
<input <input
@@ -100,16 +100,16 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
id="ip" id="ip"
value={formData.ip} value={formData.ip}
onChange={handleChange('ip')} onChange={handleChange('ip')}
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${ 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' : 'border-gray-300' errors.ip ? 'border-red-300 dark:border-red-600' : 'border-gray-300 dark:border-gray-600'
}`} }`}
placeholder="e.g., 192.168.1.100" 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>
<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 * Username *
</label> </label>
<input <input
@@ -117,16 +117,16 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
id="user" id="user"
value={formData.user} value={formData.user}
onChange={handleChange('user')} onChange={handleChange('user')}
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${ 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' : 'border-gray-300' errors.user ? 'border-red-300 dark:border-red-600' : 'border-gray-300 dark:border-gray-600'
}`} }`}
placeholder="e.g., root" 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>
<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 * Password *
</label> </label>
<input <input
@@ -134,12 +134,12 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
id="password" id="password"
value={formData.password} value={formData.password}
onChange={handleChange('password')} onChange={handleChange('password')}
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${ 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' : 'border-gray-300' errors.password ? 'border-red-300 dark:border-red-600' : 'border-gray-300 dark:border-gray-600'
}`} }`}
placeholder="Enter password" 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>
</div> </div>
@@ -148,14 +148,14 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
<button <button
type="button" type="button"
onClick={onCancel} 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 Cancel
</button> </button>
)} )}
<button <button
type="submit" 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'} {isEditing ? 'Update Server' : 'Add Server'}
</button> </button>

View File

@@ -10,7 +10,7 @@ export function SettingsButton() {
<> <>
<button <button
onClick={() => setIsOpen(true)} 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" title="Add PVE Server"
> >
<svg <svg

View File

@@ -98,14 +98,14 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <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 rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-hidden"> <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 */} {/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200"> <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">Settings</h2> <h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Settings</h2>
<button <button
onClick={onClose} 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"> <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" /> <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')} onClick={() => setActiveTab('servers')}
className={`py-4 px-1 border-b-2 font-medium text-sm ${ className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === 'servers' activeTab === 'servers'
? 'border-blue-500 text-blue-600' ? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' : '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 Server Settings
@@ -130,8 +130,8 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
onClick={() => setActiveTab('general')} onClick={() => setActiveTab('general')}
className={`py-4 px-1 border-b-2 font-medium text-sm ${ className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === 'general' activeTab === 'general'
? 'border-blue-500 text-blue-600' ? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' : '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 General
@@ -142,10 +142,10 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
{/* Content */} {/* Content */}
<div className="p-6 overflow-y-auto max-h-[calc(90vh-200px)]"> <div className="p-6 overflow-y-auto max-h-[calc(90vh-200px)]">
{error && ( {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">
<div className="flex-shrink-0"> <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" /> <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> </svg>
</div> </div>
@@ -160,14 +160,14 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
{activeTab === 'servers' && ( {activeTab === 'servers' && (
<div className="space-y-6"> <div className="space-y-6">
<div> <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} /> <ServerForm onSubmit={handleCreateServer} />
</div> </div>
<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 ? ( {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> <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> <p className="mt-2 text-gray-600">Loading servers...</p>
</div> </div>
@@ -184,8 +184,8 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
{activeTab === 'general' && ( {activeTab === 'general' && (
<div> <div>
<h3 className="text-lg font-medium text-gray-900 mb-4">General Settings</h3> <h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">General Settings</h3>
<p className="text-gray-600">General settings will be available in a future update.</p> <p className="text-gray-600 dark:text-gray-300">General settings will be available in a future update.</p>
</div> </div>
)} )}
</div> </div>

View File

@@ -4,6 +4,8 @@ import { type Metadata } from "next";
import { Geist } from "next/font/google"; import { Geist } from "next/font/google";
import { TRPCReactProvider } from "~/trpc/react"; import { TRPCReactProvider } from "~/trpc/react";
import { DarkModeProvider } from "./_components/DarkModeProvider";
import { DarkModeToggle } from "./_components/DarkModeToggle";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "PVE Scripts local", title: "PVE Scripts local",
@@ -25,8 +27,37 @@ export default function RootLayout({
}: Readonly<{ children: React.ReactNode }>) { }: Readonly<{ children: React.ReactNode }>) {
return ( return (
<html lang="en" className={`${geist.variable}`}> <html lang="en" className={`${geist.variable}`}>
<body> <head>
<TRPCReactProvider>{children}</TRPCReactProvider> <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> </body>
</html> </html>
); );

View File

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

View File

@@ -5,6 +5,8 @@
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; "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-specific styles for ANSI escape code rendering */
.terminal-output { .terminal-output {
font-family: 'Courier New', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-family: 'Courier New', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;