feat(ui): implement ConfirmDialog for enhanced user confirmations
- Introduced a reusable `ConfirmDialog` component to replace native `window.confirm()`, providing a styled modal that aligns with the app's theme. - Integrated the `ConfirmDialog` into the `App` component for global access. - Updated the `WorkspaceSection` to utilize the new confirmation dialog for profile deletion, enhancing user experience with a more consistent UI. - Added state management for selected profiles in the `ConnectionSection`, improving user interaction when selecting saved profiles. This commit enhances the application's UI by providing a more cohesive and user-friendly confirmation experience.
This commit is contained in:
parent
575ced5d99
commit
0dfe445b0e
4 changed files with 221 additions and 20 deletions
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useEffect } from 'react';
|
||||
|
||||
import { ConfirmDialog } from './components/common/ConfirmDialog';
|
||||
import { ContextSwitchOverlay } from './components/common/ContextSwitchOverlay';
|
||||
import { ErrorBoundary } from './components/common/ErrorBoundary';
|
||||
import { TabbedLayout } from './components/layout/TabbedLayout';
|
||||
|
|
@ -44,6 +45,7 @@ export const App = (): React.JSX.Element => {
|
|||
<ErrorBoundary>
|
||||
<ContextSwitchOverlay />
|
||||
<TabbedLayout />
|
||||
<ConfirmDialog />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
175
src/renderer/components/common/ConfirmDialog.tsx
Normal file
175
src/renderer/components/common/ConfirmDialog.tsx
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
/**
|
||||
* ConfirmDialog - Reusable themed confirmation dialog.
|
||||
*
|
||||
* Replaces native window.confirm() with a styled modal that matches the app theme.
|
||||
* Controlled via useConfirmDialog() hook for imperative usage.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface ConfirmDialogState {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
variant?: 'default' | 'danger';
|
||||
}
|
||||
|
||||
type ConfirmResolver = ((confirmed: boolean) => void) | null;
|
||||
|
||||
const initialState: ConfirmDialogState = {
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
};
|
||||
|
||||
// Singleton state — one dialog at a time
|
||||
let globalSetState: ((state: ConfirmDialogState) => void) | null = null;
|
||||
let globalResolver: ConfirmResolver = null;
|
||||
|
||||
/**
|
||||
* Imperatively show a themed confirm dialog. Returns a promise that resolves
|
||||
* to true (confirmed) or false (cancelled).
|
||||
*
|
||||
* Usage:
|
||||
* const confirmed = await confirm({ title: 'Delete?', message: 'This cannot be undone.' });
|
||||
*/
|
||||
export async function confirm(opts: {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
variant?: 'default' | 'danger';
|
||||
}): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
// If a previous dialog is open, resolve it as cancelled
|
||||
if (globalResolver) {
|
||||
globalResolver(false);
|
||||
}
|
||||
|
||||
globalResolver = resolve;
|
||||
globalSetState?.({
|
||||
isOpen: true,
|
||||
title: opts.title,
|
||||
message: opts.message,
|
||||
confirmLabel: opts.confirmLabel,
|
||||
cancelLabel: opts.cancelLabel,
|
||||
variant: opts.variant,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ConfirmDialog component. Mount once at the app root (e.g. in App.tsx).
|
||||
*/
|
||||
export const ConfirmDialog = (): React.JSX.Element | null => {
|
||||
const [state, setState] = useState<ConfirmDialogState>(initialState);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Register singleton setter
|
||||
useEffect(() => {
|
||||
globalSetState = setState;
|
||||
return () => {
|
||||
globalSetState = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const close = useCallback((confirmed: boolean) => {
|
||||
if (globalResolver) {
|
||||
globalResolver(confirmed);
|
||||
globalResolver = null;
|
||||
}
|
||||
setState(initialState);
|
||||
}, []);
|
||||
|
||||
// Escape key closes
|
||||
useEffect(() => {
|
||||
if (!state.isOpen) return;
|
||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') close(false);
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [state.isOpen, close]);
|
||||
|
||||
// Auto-focus confirm button
|
||||
useEffect(() => {
|
||||
if (state.isOpen && dialogRef.current) {
|
||||
const btn = dialogRef.current.querySelector<HTMLButtonElement>('[data-confirm-btn]');
|
||||
btn?.focus();
|
||||
}
|
||||
}, [state.isOpen]);
|
||||
|
||||
if (!state.isOpen) return null;
|
||||
|
||||
const isDanger = state.variant === 'danger';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<button
|
||||
className="absolute inset-0 cursor-default"
|
||||
style={{ backgroundColor: 'rgba(0, 0, 0, 0.6)' }}
|
||||
onClick={() => close(false)}
|
||||
aria-label="Close dialog"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className="relative mx-4 w-full max-w-sm rounded-lg border p-6 shadow-xl"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={state.title}
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-overlay)',
|
||||
borderColor: 'var(--color-border-emphasis)',
|
||||
}}
|
||||
>
|
||||
{/* Icon + Title */}
|
||||
<div className="flex items-start gap-3">
|
||||
{isDanger && (
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-red-500/10">
|
||||
<AlertTriangle className="size-5 text-red-400" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
|
||||
{state.title}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
{state.message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-5 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => close(false)}
|
||||
className="rounded-md border px-4 py-2 text-sm font-medium transition-colors hover:bg-white/5"
|
||||
style={{
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
{state.cancelLabel ?? 'Cancel'}
|
||||
</button>
|
||||
<button
|
||||
data-confirm-btn
|
||||
onClick={() => close(true)}
|
||||
className={`rounded-md px-4 py-2 text-sm font-medium transition-colors ${
|
||||
isDanger
|
||||
? 'bg-red-600 text-white hover:bg-red-500'
|
||||
: 'bg-zinc-600 text-white hover:bg-zinc-500'
|
||||
}`}
|
||||
>
|
||||
{state.confirmLabel ?? 'Confirm'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -61,6 +61,7 @@ export const ConnectionSection = (): React.JSX.Element => {
|
|||
|
||||
// Saved profiles
|
||||
const [savedProfiles, setSavedProfiles] = useState<SshConnectionProfile[]>([]);
|
||||
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null);
|
||||
|
||||
const loadProfiles = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -123,6 +124,8 @@ export const ConnectionSection = (): React.JSX.Element => {
|
|||
);
|
||||
}, [host, sshConfigHosts]);
|
||||
|
||||
const clearProfileSelection = (): void => setSelectedProfileId(null);
|
||||
|
||||
const handleSelectConfigHost = (entry: SshConfigHostEntry): void => {
|
||||
setHost(entry.alias);
|
||||
if (entry.port) setPort(String(entry.port));
|
||||
|
|
@ -130,6 +133,7 @@ export const ConnectionSection = (): React.JSX.Element => {
|
|||
setAuthMethod('auto');
|
||||
setShowDropdown(false);
|
||||
setTestResult(null);
|
||||
clearProfileSelection();
|
||||
};
|
||||
|
||||
const handleSelectProfile = (profile: SshConnectionProfile): void => {
|
||||
|
|
@ -140,6 +144,7 @@ export const ConnectionSection = (): React.JSX.Element => {
|
|||
if (profile.privateKeyPath) setPrivateKeyPath(profile.privateKeyPath);
|
||||
setPassword('');
|
||||
setTestResult(null);
|
||||
setSelectedProfileId(profile.id);
|
||||
};
|
||||
|
||||
const buildConfig = (): SshConnectionConfig => ({
|
||||
|
|
@ -241,24 +246,33 @@ export const ConnectionSection = (): React.JSX.Element => {
|
|||
Saved Profiles
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{savedProfiles.map((profile) => (
|
||||
<button
|
||||
key={profile.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectProfile(profile)}
|
||||
className="flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-surface-raised"
|
||||
style={{
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
<Server className="size-3.5" style={{ color: 'var(--color-text-muted)' }} />
|
||||
<span>{profile.name}</span>
|
||||
<span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||
{profile.username}@{profile.host}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{savedProfiles.map((profile) => {
|
||||
const isSelected = selectedProfileId === profile.id;
|
||||
return (
|
||||
<button
|
||||
key={profile.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectProfile(profile)}
|
||||
className={`flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors ${isSelected ? '' : 'hover:bg-surface-raised'}`}
|
||||
style={{
|
||||
borderColor: isSelected ? 'rgba(99, 102, 241, 0.4)' : 'var(--color-border)',
|
||||
backgroundColor: isSelected ? 'rgba(99, 102, 241, 0.1)' : 'transparent',
|
||||
color: isSelected ? 'var(--color-text)' : 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
<Server
|
||||
className="size-3.5"
|
||||
style={{
|
||||
color: isSelected ? 'rgb(129, 140, 248)' : 'var(--color-text-muted)',
|
||||
}}
|
||||
/>
|
||||
<span>{profile.name}</span>
|
||||
<span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||
{profile.username}@{profile.host}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -284,6 +298,7 @@ export const ConnectionSection = (): React.JSX.Element => {
|
|||
setHost(e.target.value);
|
||||
setShowDropdown(true);
|
||||
setTestResult(null);
|
||||
clearProfileSelection();
|
||||
}}
|
||||
onFocus={() => setShowDropdown(true)}
|
||||
placeholder="hostname or ssh config alias"
|
||||
|
|
@ -348,7 +363,10 @@ export const ConnectionSection = (): React.JSX.Element => {
|
|||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setUsername(e.target.value);
|
||||
clearProfileSelection();
|
||||
}}
|
||||
placeholder="user"
|
||||
className={inputClass}
|
||||
style={inputStyle}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { Edit2, Loader2, Plus, Save, Server, Trash2, X } from 'lucide-react';
|
||||
|
||||
|
|
@ -143,7 +144,12 @@ export const WorkspaceSection = (): React.JSX.Element => {
|
|||
const profile = profiles.find((p) => p.id === id);
|
||||
if (!profile) return;
|
||||
|
||||
const confirmed = window.confirm(`Delete profile "${profile.name}"?`);
|
||||
const confirmed = await confirm({
|
||||
title: 'Delete Profile',
|
||||
message: `Are you sure you want to delete "${profile.name}"? This cannot be undone.`,
|
||||
confirmLabel: 'Delete',
|
||||
variant: 'danger',
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
const filtered = profiles.filter((p) => p.id !== id);
|
||||
|
|
|
|||
Loading…
Reference in a new issue