diff --git a/src/app/_components/DarkModeProvider.tsx b/src/app/_components/DarkModeProvider.tsx new file mode 100644 index 0000000..2991bf7 --- /dev/null +++ b/src/app/_components/DarkModeProvider.tsx @@ -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(undefined); + +export function DarkModeProvider({ children }: { children: React.ReactNode }) { + const [theme, setThemeState] = useState('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 ( + + {children} + + ); +} + +export function useDarkMode() { + const context = useContext(DarkModeContext); + if (context === undefined) { + throw new Error('useDarkMode must be used within a DarkModeProvider'); + } + return context; +} \ No newline at end of file diff --git a/src/app/_components/DarkModeToggle.tsx b/src/app/_components/DarkModeToggle.tsx new file mode 100644 index 0000000..5cccd61 --- /dev/null +++ b/src/app/_components/DarkModeToggle.tsx @@ -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 ( + + + + ); + } else if (theme === 'dark') { + return ( + + + + ); + } else { + // System theme icon + return ( + + + + ); + } + }; + + const getLabel = () => { + if (theme === 'light') return 'Light mode'; + if (theme === 'dark') return 'Dark mode'; + return 'System theme'; + }; + + return ( + + ); +} \ No newline at end of file diff --git a/src/app/_components/InstalledScriptsTab.tsx b/src/app/_components/InstalledScriptsTab.tsx index 17f2f4a..2db0ea9 100644 --- a/src/app/_components/InstalledScriptsTab.tsx +++ b/src/app/_components/InstalledScriptsTab.tsx @@ -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 (
-
Loading installed scripts...
+
Loading installed scripts...
); } @@ -160,8 +160,8 @@ export function InstalledScriptsTab() { )} {/* Header with Stats */} -
-

Installed Scripts

+
+

Installed Scripts

{stats && (
@@ -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" />
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" > @@ -222,60 +222,57 @@ export function InstalledScriptsTab() {
{/* Scripts Table */} -
+
{filteredScripts.length === 0 ? ( -
+
{scripts.length === 0 ? 'No installed scripts found.' : 'No scripts match your filters.'}
) : (
- - +
+ - - - - - - - - + {filteredScripts.map((script) => ( @@ -289,7 +286,7 @@ export function InstalledScriptsTab() { {String(script.status).replace('_', ' ').toUpperCase()} -
+ Script Name + Container ID + Server - Mode - + Status - Date + + Installation Date + Actions
-
{script.script_name}
-
{script.script_path}
+
{script.script_name}
+
{script.script_path}
{script.container_id ? ( - {String(script.container_id)} + {String(script.container_id)} ) : ( - - + - )} {script.execution_mode === 'local' ? ( - Local + Local ) : (
-
{script.server_name}
-
{script.server_ip}
+
{script.server_name}
+
{script.server_ip}
)}
+ {formatDate(String(script.installation_date))} diff --git a/src/app/_components/ResyncButton.tsx b/src/app/_components/ResyncButton.tsx index 7ceb67a..fb02882 100644 --- a/src/app/_components/ResyncButton.tsx +++ b/src/app/_components/ResyncButton.tsx @@ -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() { {lastSync && ( -
+
Last sync: {lastSync.toLocaleTimeString()}
)} @@ -72,8 +72,8 @@ export function ResyncButton() { {syncMessage && (
{syncMessage}
diff --git a/src/app/_components/ScriptCard.tsx b/src/app/_components/ScriptCard.tsx index c3bb58f..85ad8c7 100644 --- a/src/app/_components/ScriptCard.tsx +++ b/src/app/_components/ScriptCard.tsx @@ -18,7 +18,7 @@ export function ScriptCard({ script, onClick }: ScriptCardProps) { return (
onClick(script)} >
@@ -35,15 +35,15 @@ export function ScriptCard({ script, onClick }: ScriptCardProps) { onError={handleImageError} /> ) : ( -
- +
+ {script.name?.charAt(0)?.toUpperCase() || '?'}
)}
-

+

{script.name || 'Unnamed Script'}

@@ -51,15 +51,15 @@ export function ScriptCard({ script, onClick }: ScriptCardProps) {
{script.type?.toUpperCase() || 'UNKNOWN'} {script.updateable && ( - + Updateable )} @@ -71,7 +71,7 @@ export function ScriptCard({ script, onClick }: ScriptCardProps) { script.isDownloaded ? 'bg-green-500' : 'bg-red-500' }`}>
{script.isDownloaded ? 'Downloaded' : 'Not Downloaded'} @@ -81,7 +81,7 @@ export function ScriptCard({ script, onClick }: ScriptCardProps) {
{/* Description */} -

+

{script.description || 'No description available'}

@@ -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()} > Website diff --git a/src/app/_components/ScriptDetailModal.tsx b/src/app/_components/ScriptDetailModal.tsx index 07fae89..89ccbf8 100644 --- a/src/app/_components/ScriptDetailModal.tsx +++ b/src/app/_components/ScriptDetailModal.tsx @@ -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(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 (
-
+
{/* Header */} -
+
{script.logo && !imageError ? ( {`${script.name} ) : ( -
- +
+ {script.name.charAt(0).toUpperCase()}
)}
-

{script.name}

-
- +

+ {script.name} +

+
+ {script.type.toUpperCase()} {script.updateable && ( - + Updateable )} {script.privileged && ( - + Privileged )} @@ -159,59 +183,100 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
{/* Install Button - only show if script files exist */} - {scriptFilesData?.success && scriptFilesData.ctExists && onInstallScript && ( - - )} + {scriptFilesData?.success && + scriptFilesData.ctExists && + onInstallScript && ( + + )} {/* View Button - only show if script files exist */} - {scriptFilesData?.success && (scriptFilesData.ctExists || scriptFilesData.installExists) && ( - - )} - + {scriptFilesData?.success && + (scriptFilesData.ctExists || scriptFilesData.installExists) && ( + + )} + {/* 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 ( @@ -237,21 +312,31 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
@@ -273,114 +368,169 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }: {/* Load Message */} {loadMessage && ( -
+
{loadMessage}
)} {/* Script Files Status */} {(scriptFilesLoading || comparisonLoading) && ( -
+
-
+
Loading script status...
)} - - {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 ( -
-
-
-
- {scriptType}: {scriptFilesData.ctExists ? 'Available' : 'Not loaded'} -
-
-
- Install Script: {scriptFilesData.installExists ? 'Available' : 'Not loaded'} -
- {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 ( +
+
-
- Status: {comparisonData.hasDifferences ? 'Update available' : 'Up to date'} +
+ + {scriptType}:{" "} + {scriptFilesData.ctExists ? "Available" : "Not loaded"} + +
+
+
+ + Install Script:{" "} + {scriptFilesData.installExists + ? "Available" + : "Not loaded"} + +
+ {scriptFilesData?.success && + (scriptFilesData.ctExists || + scriptFilesData.installExists) && + comparisonData?.success && + !comparisonLoading && ( +
+
+ + Status:{" "} + {comparisonData.hasDifferences + ? "Update available" + : "Up to date"} + +
+ )} +
+ {scriptFilesData.files.length > 0 && ( +
+ Files: {scriptFilesData.files.join(", ")}
)}
- {scriptFilesData.files.length > 0 && ( -
- Files: {scriptFilesData.files.join(', ')} -
- )} -
- ); - })()} + ); + })()} {/* Content */} -
+
{/* Description */}
-

Description

-

{script.description}

+

+ Description +

+

+ {script.description} +

{/* Basic Information */} -
+
-

Basic Information

+

+ Basic Information +

-
Slug
-
{script.slug}
+
+ Slug +
+
+ {script.slug} +
-
Date Created
-
{script.date_created}
+
+ Date Created +
+
+ {script.date_created} +
-
Categories
-
{script.categories.join(', ')}
+
+ Categories +
+
+ {script.categories.join(", ")} +
{script.interface_port && (
-
Interface Port
-
{script.interface_port}
+
+ Interface Port +
+
+ {script.interface_port} +
)} {script.config_path && (
-
Config Path
-
{script.config_path}
+
+ Config Path +
+
+ {script.config_path} +
)}
-

Links

+

+ Links +

{script.website && (
-
Website
+
+ Website +
{script.website} @@ -389,13 +539,15 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }: )} {script.documentation && (
-
Documentation
+
+ Documentation +
{script.documentation} @@ -406,56 +558,94 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
- {/* Install Methods */} - {script.install_methods.length > 0 && ( -
-

Install Methods

-
- {script.install_methods.map((method, index) => ( -
-
-

{method.type}

- {method.script} -
-
-
-
CPU
-
{method.resources.cpu} cores
-
-
-
RAM
-
{method.resources.ram} MB
-
-
-
HDD
-
{method.resources.hdd} GB
+ {/* 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" && ( +
+

+ Install Methods +

+
+ {script.install_methods.map((method, index) => ( +
+
+

+ {method.type} +

+ + {method.script} +
-
-
OS
-
{method.resources.os} {method.resources.version}
+
+
+
+ CPU +
+
+ {method.resources.cpu} cores +
+
+
+
+ RAM +
+
+ {method.resources.ram} MB +
+
+
+
+ HDD +
+
+ {method.resources.hdd} GB +
+
+
+
+ OS +
+
+ {method.resources.os} {method.resources.version} +
+
-
- ))} + ))} +
-
- )} + )} {/* Default Credentials */} - {(script.default_credentials.username ?? script.default_credentials.password) && ( + {(script.default_credentials.username ?? + script.default_credentials.password) && (
-

Default Credentials

+

+ Default Credentials +

{script.default_credentials.username && (
-
Username
-
{script.default_credentials.username}
+
+ Username +
+
+ {script.default_credentials.username} +
)} {script.default_credentials.password && (
-
Password
-
{script.default_credentials.password}
+
+ Password +
+
+ {script.default_credentials.password} +
)}
@@ -465,29 +655,37 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }: {/* Notes */} {script.notes.length > 0 && (
-

Notes

+

+ Notes +

    {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 ( -
  • +
  • - + {noteType} {noteText} @@ -517,7 +715,12 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }: {/* Text Viewer Modal */} {script && ( 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)} /> diff --git a/src/app/_components/ScriptsGrid.tsx b/src/app/_components/ScriptsGrid.tsx index 6d7dae4..30a56b2 100644 --- a/src/app/_components/ScriptsGrid.tsx +++ b/src/app/_components/ScriptsGrid.tsx @@ -155,7 +155,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
    - +
    @@ -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 && ( )} diff --git a/src/app/_components/SettingsButton.tsx b/src/app/_components/SettingsButton.tsx index 0d26422..9b16cb5 100644 --- a/src/app/_components/SettingsButton.tsx +++ b/src/app/_components/SettingsButton.tsx @@ -10,7 +10,7 @@ export function SettingsButton() { <>