Merge pull request #203 from community-scripts/feat/global-esc-close
fix/194 Add global Escape-to-close for custom modals
This commit is contained in:
@@ -5,12 +5,14 @@ import { Button } from './ui/button';
|
|||||||
import { Input } from './ui/input';
|
import { Input } from './ui/input';
|
||||||
import { useAuth } from './AuthProvider';
|
import { useAuth } from './AuthProvider';
|
||||||
import { Lock, User, AlertCircle } from 'lucide-react';
|
import { Lock, User, AlertCircle } from 'lucide-react';
|
||||||
|
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||||
|
|
||||||
interface AuthModalProps {
|
interface AuthModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AuthModal({ isOpen }: AuthModalProps) {
|
export function AuthModal({ isOpen }: AuthModalProps) {
|
||||||
|
useRegisterModal(isOpen, { id: 'auth-modal', allowEscape: false, onClose: () => null });
|
||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { AlertTriangle, Info } from 'lucide-react';
|
import { AlertTriangle, Info } from 'lucide-react';
|
||||||
|
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||||
|
|
||||||
interface ConfirmationModalProps {
|
interface ConfirmationModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -28,10 +29,12 @@ export function ConfirmationModal({
|
|||||||
cancelButtonText = 'Cancel'
|
cancelButtonText = 'Cancel'
|
||||||
}: ConfirmationModalProps) {
|
}: ConfirmationModalProps) {
|
||||||
const [typedText, setTypedText] = useState('');
|
const [typedText, setTypedText] = useState('');
|
||||||
|
const isDanger = variant === 'danger';
|
||||||
|
const allowEscape = useMemo(() => !isDanger, [isDanger]);
|
||||||
|
|
||||||
|
useRegisterModal(isOpen, { id: 'confirmation-modal', allowEscape, onClose });
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const isDanger = variant === 'danger';
|
|
||||||
const isConfirmEnabled = isDanger ? typedText === confirmText : true;
|
const isConfirmEnabled = isDanger ? typedText === confirmText : true;
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { AlertCircle, CheckCircle } from 'lucide-react';
|
import { AlertCircle, CheckCircle } from 'lucide-react';
|
||||||
|
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||||
|
|
||||||
interface ErrorModalProps {
|
interface ErrorModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -21,6 +22,7 @@ export function ErrorModal({
|
|||||||
details,
|
details,
|
||||||
type = 'error'
|
type = 'error'
|
||||||
}: ErrorModalProps) {
|
}: ErrorModalProps) {
|
||||||
|
useRegisterModal(isOpen, { id: 'error-modal', allowEscape: true, onClose });
|
||||||
// Auto-close after 10 seconds
|
// Auto-close after 10 seconds
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { Server } from '../../types/server';
|
|||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { ColorCodedDropdown } from './ColorCodedDropdown';
|
import { ColorCodedDropdown } from './ColorCodedDropdown';
|
||||||
import { SettingsModal } from './SettingsModal';
|
import { SettingsModal } from './SettingsModal';
|
||||||
|
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||||
|
|
||||||
|
|
||||||
interface ExecutionModeModalProps {
|
interface ExecutionModeModalProps {
|
||||||
@@ -15,6 +16,7 @@ interface ExecutionModeModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: ExecutionModeModalProps) {
|
export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: ExecutionModeModalProps) {
|
||||||
|
useRegisterModal(isOpen, { id: 'execution-mode-modal', allowEscape: true, onClose });
|
||||||
const [servers, setServers] = useState<Server[]>([]);
|
const [servers, setServers] = useState<Server[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Input } from './ui/input';
|
|||||||
import { Toggle } from './ui/toggle';
|
import { Toggle } from './ui/toggle';
|
||||||
import { ContextualHelpIcon } from './ContextualHelpIcon';
|
import { ContextualHelpIcon } from './ContextualHelpIcon';
|
||||||
import { useTheme } from './ThemeProvider';
|
import { useTheme } from './ThemeProvider';
|
||||||
|
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||||
|
|
||||||
interface GeneralSettingsModalProps {
|
interface GeneralSettingsModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -13,6 +14,7 @@ interface GeneralSettingsModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalProps) {
|
export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalProps) {
|
||||||
|
useRegisterModal(isOpen, { id: 'general-settings-modal', allowEscape: true, onClose });
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const [activeTab, setActiveTab] = useState<'general' | 'github' | 'auth'>('general');
|
const [activeTab, setActiveTab] = useState<'general' | 'github' | 'auth'>('general');
|
||||||
const [githubToken, setGithubToken] = useState('');
|
const [githubToken, setGithubToken] = useState('');
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { HelpCircle, Server, Settings, RefreshCw, Package, HardDrive, FolderOpen, Search, Download } from 'lucide-react';
|
import { HelpCircle, Server, Settings, RefreshCw, Package, HardDrive, FolderOpen, Search, Download } from 'lucide-react';
|
||||||
|
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||||
|
|
||||||
interface HelpModalProps {
|
interface HelpModalProps {
|
||||||
isOpen: boolean;
|
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';
|
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) {
|
export function HelpModal({ isOpen, onClose, initialSection = 'server-settings' }: HelpModalProps) {
|
||||||
|
useRegisterModal(isOpen, { id: 'help-modal', allowEscape: true, onClose });
|
||||||
const [activeSection, setActiveSection] = useState<HelpSection>(initialSection as HelpSection);
|
const [activeSection, setActiveSection] = useState<HelpSection>(initialSection as HelpSection);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ContextualHelpIcon } from './ContextualHelpIcon';
|
|||||||
import { LoadingModal } from './LoadingModal';
|
import { LoadingModal } from './LoadingModal';
|
||||||
import { ConfirmationModal } from './ConfirmationModal';
|
import { ConfirmationModal } from './ConfirmationModal';
|
||||||
import { RefreshCw, AlertTriangle, CheckCircle } from 'lucide-react';
|
import { RefreshCw, AlertTriangle, CheckCircle } from 'lucide-react';
|
||||||
|
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||||
|
|
||||||
interface InstalledScript {
|
interface InstalledScript {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -41,6 +42,7 @@ interface LXCSettingsModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function LXCSettingsModal({ isOpen, script, onClose, onSave: _onSave }: LXCSettingsModalProps) {
|
export function LXCSettingsModal({ isOpen, script, onClose, onSave: _onSave }: LXCSettingsModalProps) {
|
||||||
|
useRegisterModal(isOpen, { id: 'lxc-settings-modal', allowEscape: true, onClose });
|
||||||
const [activeTab, setActiveTab] = useState<string>('common');
|
const [activeTab, setActiveTab] = useState<string>('common');
|
||||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||||
const [showResultModal, setShowResultModal] = useState(false);
|
const [showResultModal, setShowResultModal] = useState(false);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||||
|
|
||||||
interface LoadingModalProps {
|
interface LoadingModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -8,6 +9,7 @@ interface LoadingModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function LoadingModal({ isOpen, action }: LoadingModalProps) {
|
export function LoadingModal({ isOpen, action }: LoadingModalProps) {
|
||||||
|
useRegisterModal(isOpen, { id: 'loading-modal', allowEscape: false, onClose: () => null });
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { X, Copy, Check, Server, Globe } from 'lucide-react';
|
import { X, Copy, Check, Server, Globe } from 'lucide-react';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
|
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||||
|
|
||||||
interface PublicKeyModalProps {
|
interface PublicKeyModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -13,6 +14,7 @@ interface PublicKeyModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PublicKeyModal({ isOpen, onClose, publicKey, serverName, serverIp }: 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 [copied, setCopied] = useState(false);
|
||||||
const [commandCopied, setCommandCopied] = useState(false);
|
const [commandCopied, setCommandCopied] = useState(false);
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { api } from '~/trpc/react';
|
|||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { Badge } from './ui/badge';
|
import { Badge } from './ui/badge';
|
||||||
import { X, ExternalLink, Calendar, Tag, Loader2 } from 'lucide-react';
|
import { X, ExternalLink, Calendar, Tag, Loader2 } from 'lucide-react';
|
||||||
|
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ const markVersionAsSeen = (version: string): void => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function ReleaseNotesModal({ isOpen, onClose, highlightVersion }: ReleaseNotesModalProps) {
|
export function ReleaseNotesModal({ isOpen, onClose, highlightVersion }: ReleaseNotesModalProps) {
|
||||||
|
useRegisterModal(isOpen, { id: 'release-notes-modal', allowEscape: true, onClose });
|
||||||
const [currentVersion, setCurrentVersion] = useState<string | null>(null);
|
const [currentVersion, setCurrentVersion] = useState<string | null>(null);
|
||||||
const { data: releasesData, isLoading, error } = api.version.getAllReleases.useQuery(undefined, {
|
const { data: releasesData, isLoading, error } = api.version.getAllReleases.useQuery(undefined, {
|
||||||
enabled: isOpen
|
enabled: isOpen
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { TextViewer } from "./TextViewer";
|
|||||||
import { ExecutionModeModal } from "./ExecutionModeModal";
|
import { ExecutionModeModal } from "./ExecutionModeModal";
|
||||||
import { TypeBadge, UpdateableBadge, PrivilegedBadge, NoteBadge } from "./Badge";
|
import { TypeBadge, UpdateableBadge, PrivilegedBadge, NoteBadge } from "./Badge";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
|
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||||
|
|
||||||
interface ScriptDetailModalProps {
|
interface ScriptDetailModalProps {
|
||||||
script: Script | null;
|
script: Script | null;
|
||||||
@@ -28,6 +29,7 @@ export function ScriptDetailModal({
|
|||||||
onClose,
|
onClose,
|
||||||
onInstallScript,
|
onInstallScript,
|
||||||
}: ScriptDetailModalProps) {
|
}: ScriptDetailModalProps) {
|
||||||
|
useRegisterModal(isOpen, { id: 'script-detail-modal', allowEscape: true, onClose });
|
||||||
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);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ServerForm } from './ServerForm';
|
|||||||
import { ServerList } from './ServerList';
|
import { ServerList } from './ServerList';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { ContextualHelpIcon } from './ContextualHelpIcon';
|
import { ContextualHelpIcon } from './ContextualHelpIcon';
|
||||||
|
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||||
|
|
||||||
interface SettingsModalProps {
|
interface SettingsModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -13,6 +14,7 @@ interface SettingsModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||||
|
useRegisterModal(isOpen, { id: 'settings-modal', allowEscape: true, onClose });
|
||||||
const [servers, setServers] = useState<Server[]>([]);
|
const [servers, setServers] = useState<Server[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Button } from './ui/button';
|
|||||||
import { Input } from './ui/input';
|
import { Input } from './ui/input';
|
||||||
import { Toggle } from './ui/toggle';
|
import { Toggle } from './ui/toggle';
|
||||||
import { Lock, User, Shield, AlertCircle } from 'lucide-react';
|
import { Lock, User, Shield, AlertCircle } from 'lucide-react';
|
||||||
|
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||||
|
|
||||||
interface SetupModalProps {
|
interface SetupModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -12,6 +13,7 @@ interface SetupModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SetupModal({ isOpen, onComplete }: SetupModalProps) {
|
export function SetupModal({ isOpen, onComplete }: SetupModalProps) {
|
||||||
|
useRegisterModal(isOpen, { id: 'setup-modal', allowEscape: true, onClose: () => null });
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
|||||||
57
src/app/_components/modal/ModalStackProvider.tsx
Normal file
57
src/app/_components/modal/ModalStackProvider.tsx
Normal file
@@ -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<ModalStackContextValue | null>(null);
|
||||||
|
|
||||||
|
export function ModalStackProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const stackRef = useRef<RegisteredModal[]>([]);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<ModalStackContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</ModalStackContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRegisterModal(enabled: boolean, modal: RegisteredModal) {
|
||||||
|
const ctx = useContext(ModalStackContext);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ctx || !enabled) return;
|
||||||
|
return ctx.register(modal);
|
||||||
|
}, [ctx, enabled, modal]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@ import { TRPCReactProvider } from "~/trpc/react";
|
|||||||
import { AuthProvider } from "./_components/AuthProvider";
|
import { AuthProvider } from "./_components/AuthProvider";
|
||||||
import { AuthGuard } from "./_components/AuthGuard";
|
import { AuthGuard } from "./_components/AuthGuard";
|
||||||
import { ThemeProvider } from "./_components/ThemeProvider";
|
import { ThemeProvider } from "./_components/ThemeProvider";
|
||||||
|
import { ModalStackProvider } from "./_components/modal/ModalStackProvider";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "PVE Scripts local",
|
title: "PVE Scripts local",
|
||||||
@@ -41,9 +42,11 @@ export default function RootLayout({
|
|||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<TRPCReactProvider>
|
<TRPCReactProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<AuthGuard>
|
<ModalStackProvider>
|
||||||
{children}
|
<AuthGuard>
|
||||||
</AuthGuard>
|
{children}
|
||||||
|
</AuthGuard>
|
||||||
|
</ModalStackProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</TRPCReactProvider>
|
</TRPCReactProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
Reference in New Issue
Block a user