diff --git a/src/app/_components/AuthModal.tsx b/src/app/_components/AuthModal.tsx index cb56602..271509f 100644 --- a/src/app/_components/AuthModal.tsx +++ b/src/app/_components/AuthModal.tsx @@ -5,12 +5,14 @@ import { Button } from './ui/button'; import { Input } from './ui/input'; import { useAuth } from './AuthProvider'; import { Lock, User, AlertCircle } from 'lucide-react'; +import { useRegisterModal } from './modal/ModalStackProvider'; interface AuthModalProps { isOpen: boolean; } export function AuthModal({ isOpen }: AuthModalProps) { + useRegisterModal(isOpen, { id: 'auth-modal', allowEscape: false, onClose: () => null }); const { login } = useAuth(); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); diff --git a/src/app/_components/ConfirmationModal.tsx b/src/app/_components/ConfirmationModal.tsx index 739a289..cfe8b69 100644 --- a/src/app/_components/ConfirmationModal.tsx +++ b/src/app/_components/ConfirmationModal.tsx @@ -1,8 +1,9 @@ 'use client'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { Button } from './ui/button'; import { AlertTriangle, Info } from 'lucide-react'; +import { useRegisterModal } from './modal/ModalStackProvider'; interface ConfirmationModalProps { isOpen: boolean; @@ -28,10 +29,12 @@ export function ConfirmationModal({ cancelButtonText = 'Cancel' }: ConfirmationModalProps) { const [typedText, setTypedText] = useState(''); + const isDanger = variant === 'danger'; + const allowEscape = useMemo(() => !isDanger, [isDanger]); + + useRegisterModal(isOpen, { id: 'confirmation-modal', allowEscape, onClose }); if (!isOpen) return null; - - const isDanger = variant === 'danger'; const isConfirmEnabled = isDanger ? typedText === confirmText : true; const handleConfirm = () => { diff --git a/src/app/_components/ErrorModal.tsx b/src/app/_components/ErrorModal.tsx index db79384..5a81682 100644 --- a/src/app/_components/ErrorModal.tsx +++ b/src/app/_components/ErrorModal.tsx @@ -3,6 +3,7 @@ import { useEffect } from 'react'; import { Button } from './ui/button'; import { AlertCircle, CheckCircle } from 'lucide-react'; +import { useRegisterModal } from './modal/ModalStackProvider'; interface ErrorModalProps { isOpen: boolean; @@ -21,6 +22,7 @@ export function ErrorModal({ details, type = 'error' }: ErrorModalProps) { + useRegisterModal(isOpen, { id: 'error-modal', allowEscape: true, onClose }); // Auto-close after 10 seconds useEffect(() => { if (isOpen) { diff --git a/src/app/_components/ExecutionModeModal.tsx b/src/app/_components/ExecutionModeModal.tsx index 0e94ebb..bc715c1 100644 --- a/src/app/_components/ExecutionModeModal.tsx +++ b/src/app/_components/ExecutionModeModal.tsx @@ -5,6 +5,7 @@ import type { Server } from '../../types/server'; import { Button } from './ui/button'; import { ColorCodedDropdown } from './ColorCodedDropdown'; import { SettingsModal } from './SettingsModal'; +import { useRegisterModal } from './modal/ModalStackProvider'; interface ExecutionModeModalProps { @@ -15,6 +16,7 @@ interface ExecutionModeModalProps { } export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: ExecutionModeModalProps) { + useRegisterModal(isOpen, { id: 'execution-mode-modal', allowEscape: true, onClose }); const [servers, setServers] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); diff --git a/src/app/_components/GeneralSettingsModal.tsx b/src/app/_components/GeneralSettingsModal.tsx index 6f829cd..364832a 100644 --- a/src/app/_components/GeneralSettingsModal.tsx +++ b/src/app/_components/GeneralSettingsModal.tsx @@ -6,6 +6,7 @@ import { Input } from './ui/input'; import { Toggle } from './ui/toggle'; import { ContextualHelpIcon } from './ContextualHelpIcon'; import { useTheme } from './ThemeProvider'; +import { useRegisterModal } from './modal/ModalStackProvider'; interface GeneralSettingsModalProps { isOpen: boolean; @@ -13,6 +14,7 @@ interface GeneralSettingsModalProps { } export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalProps) { + useRegisterModal(isOpen, { id: 'general-settings-modal', allowEscape: true, onClose }); const { theme, setTheme } = useTheme(); const [activeTab, setActiveTab] = useState<'general' | 'github' | 'auth'>('general'); const [githubToken, setGithubToken] = useState(''); diff --git a/src/app/_components/HelpModal.tsx b/src/app/_components/HelpModal.tsx index e234f5d..cd1e475 100644 --- a/src/app/_components/HelpModal.tsx +++ b/src/app/_components/HelpModal.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import { Button } from './ui/button'; import { HelpCircle, Server, Settings, RefreshCw, Package, HardDrive, FolderOpen, Search, Download } from 'lucide-react'; +import { useRegisterModal } from './modal/ModalStackProvider'; interface HelpModalProps { isOpen: boolean; @@ -13,6 +14,7 @@ interface HelpModalProps { type HelpSection = 'server-settings' | 'general-settings' | 'sync-button' | 'available-scripts' | 'downloaded-scripts' | 'installed-scripts' | 'lxc-settings' | 'update-system'; export function HelpModal({ isOpen, onClose, initialSection = 'server-settings' }: HelpModalProps) { + useRegisterModal(isOpen, { id: 'help-modal', allowEscape: true, onClose }); const [activeSection, setActiveSection] = useState(initialSection as HelpSection); if (!isOpen) return null; diff --git a/src/app/_components/LXCSettingsModal.tsx b/src/app/_components/LXCSettingsModal.tsx index 39b5ecb..6861682 100644 --- a/src/app/_components/LXCSettingsModal.tsx +++ b/src/app/_components/LXCSettingsModal.tsx @@ -9,6 +9,7 @@ import { ContextualHelpIcon } from './ContextualHelpIcon'; import { LoadingModal } from './LoadingModal'; import { ConfirmationModal } from './ConfirmationModal'; import { RefreshCw, AlertTriangle, CheckCircle } from 'lucide-react'; +import { useRegisterModal } from './modal/ModalStackProvider'; interface InstalledScript { id: number; @@ -41,6 +42,7 @@ interface LXCSettingsModalProps { } export function LXCSettingsModal({ isOpen, script, onClose, onSave: _onSave }: LXCSettingsModalProps) { + useRegisterModal(isOpen, { id: 'lxc-settings-modal', allowEscape: true, onClose }); const [activeTab, setActiveTab] = useState('common'); const [showConfirmation, setShowConfirmation] = useState(false); const [showResultModal, setShowResultModal] = useState(false); diff --git a/src/app/_components/LoadingModal.tsx b/src/app/_components/LoadingModal.tsx index 846c2d7..00d57ac 100644 --- a/src/app/_components/LoadingModal.tsx +++ b/src/app/_components/LoadingModal.tsx @@ -1,6 +1,7 @@ 'use client'; import { Loader2 } from 'lucide-react'; +import { useRegisterModal } from './modal/ModalStackProvider'; interface LoadingModalProps { isOpen: boolean; @@ -8,6 +9,7 @@ interface LoadingModalProps { } export function LoadingModal({ isOpen, action }: LoadingModalProps) { + useRegisterModal(isOpen, { id: 'loading-modal', allowEscape: false, onClose: () => null }); if (!isOpen) return null; return ( diff --git a/src/app/_components/PublicKeyModal.tsx b/src/app/_components/PublicKeyModal.tsx index 980100b..91065a5 100644 --- a/src/app/_components/PublicKeyModal.tsx +++ b/src/app/_components/PublicKeyModal.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import { X, Copy, Check, Server, Globe } from 'lucide-react'; import { Button } from './ui/button'; +import { useRegisterModal } from './modal/ModalStackProvider'; interface PublicKeyModalProps { isOpen: boolean; @@ -13,6 +14,7 @@ interface PublicKeyModalProps { } export function PublicKeyModal({ isOpen, onClose, publicKey, serverName, serverIp }: PublicKeyModalProps) { + useRegisterModal(isOpen, { id: 'public-key-modal', allowEscape: true, onClose }); const [copied, setCopied] = useState(false); const [commandCopied, setCommandCopied] = useState(false); diff --git a/src/app/_components/ReleaseNotesModal.tsx b/src/app/_components/ReleaseNotesModal.tsx index 3170967..96b4ed5 100644 --- a/src/app/_components/ReleaseNotesModal.tsx +++ b/src/app/_components/ReleaseNotesModal.tsx @@ -5,6 +5,7 @@ import { api } from '~/trpc/react'; import { Button } from './ui/button'; import { Badge } from './ui/badge'; import { X, ExternalLink, Calendar, Tag, Loader2 } from 'lucide-react'; +import { useRegisterModal } from './modal/ModalStackProvider'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; @@ -34,6 +35,7 @@ const markVersionAsSeen = (version: string): void => { }; export function ReleaseNotesModal({ isOpen, onClose, highlightVersion }: ReleaseNotesModalProps) { + useRegisterModal(isOpen, { id: 'release-notes-modal', allowEscape: true, onClose }); const [currentVersion, setCurrentVersion] = useState(null); const { data: releasesData, isLoading, error } = api.version.getAllReleases.useQuery(undefined, { enabled: isOpen diff --git a/src/app/_components/ScriptDetailModal.tsx b/src/app/_components/ScriptDetailModal.tsx index 656d973..3801a7c 100644 --- a/src/app/_components/ScriptDetailModal.tsx +++ b/src/app/_components/ScriptDetailModal.tsx @@ -9,6 +9,7 @@ import { TextViewer } from "./TextViewer"; import { ExecutionModeModal } from "./ExecutionModeModal"; import { TypeBadge, UpdateableBadge, PrivilegedBadge, NoteBadge } from "./Badge"; import { Button } from "./ui/button"; +import { useRegisterModal } from './modal/ModalStackProvider'; interface ScriptDetailModalProps { script: Script | null; @@ -28,6 +29,7 @@ export function ScriptDetailModal({ onClose, onInstallScript, }: ScriptDetailModalProps) { + useRegisterModal(isOpen, { id: 'script-detail-modal', allowEscape: true, onClose }); const [imageError, setImageError] = useState(false); const [isLoading, setIsLoading] = useState(false); const [loadMessage, setLoadMessage] = useState(null); diff --git a/src/app/_components/SettingsModal.tsx b/src/app/_components/SettingsModal.tsx index 3aba9f4..6626265 100644 --- a/src/app/_components/SettingsModal.tsx +++ b/src/app/_components/SettingsModal.tsx @@ -6,6 +6,7 @@ import { ServerForm } from './ServerForm'; import { ServerList } from './ServerList'; import { Button } from './ui/button'; import { ContextualHelpIcon } from './ContextualHelpIcon'; +import { useRegisterModal } from './modal/ModalStackProvider'; interface SettingsModalProps { isOpen: boolean; @@ -13,6 +14,7 @@ interface SettingsModalProps { } export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { + useRegisterModal(isOpen, { id: 'settings-modal', allowEscape: true, onClose }); const [servers, setServers] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); diff --git a/src/app/_components/SetupModal.tsx b/src/app/_components/SetupModal.tsx index 5035708..77fd59e 100644 --- a/src/app/_components/SetupModal.tsx +++ b/src/app/_components/SetupModal.tsx @@ -5,6 +5,7 @@ import { Button } from './ui/button'; import { Input } from './ui/input'; import { Toggle } from './ui/toggle'; import { Lock, User, Shield, AlertCircle } from 'lucide-react'; +import { useRegisterModal } from './modal/ModalStackProvider'; interface SetupModalProps { isOpen: boolean; @@ -12,6 +13,7 @@ interface SetupModalProps { } export function SetupModal({ isOpen, onComplete }: SetupModalProps) { + useRegisterModal(isOpen, { id: 'setup-modal', allowEscape: true, onClose: () => null }); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); diff --git a/src/app/_components/modal/ModalStackProvider.tsx b/src/app/_components/modal/ModalStackProvider.tsx new file mode 100644 index 0000000..da0e3c8 --- /dev/null +++ b/src/app/_components/modal/ModalStackProvider.tsx @@ -0,0 +1,57 @@ +'use client'; + +import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef } from 'react'; + +type RegisteredModal = { id: string; allowEscape: boolean; onClose: () => void }; + +interface ModalStackContextValue { + register: (modal: RegisteredModal) => () => void; +} + +const ModalStackContext = createContext(null); + +export function ModalStackProvider({ children }: { children: React.ReactNode }) { + const stackRef = useRef([]); + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'Escape') return; + + for (let i = stackRef.current.length - 1; i >= 0; i -= 1) { + const modal = stackRef.current[i]; + if (modal?.allowEscape) { + modal.onClose(); + break; + } + } + }; + + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, []); + + const register = useCallback((modal: RegisteredModal) => { + stackRef.current.push(modal); + return () => { + stackRef.current = stackRef.current.filter((m) => m !== modal); + }; + }, []); + + const value = useMemo(() => ({ register }), [register]); + + return ( + + {children} + + ); +} + +export function useRegisterModal(enabled: boolean, modal: RegisteredModal) { + const ctx = useContext(ModalStackContext); + useEffect(() => { + if (!ctx || !enabled) return; + return ctx.register(modal); + }, [ctx, enabled, modal]); +} + + diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 9d61099..42c1e17 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -7,6 +7,7 @@ import { TRPCReactProvider } from "~/trpc/react"; import { AuthProvider } from "./_components/AuthProvider"; import { AuthGuard } from "./_components/AuthGuard"; import { ThemeProvider } from "./_components/ThemeProvider"; +import { ModalStackProvider } from "./_components/modal/ModalStackProvider"; export const metadata: Metadata = { title: "PVE Scripts local", @@ -41,9 +42,11 @@ export default function RootLayout({ - - {children} - + + + {children} + +