ux: in-app confirm for asset/sitesmith delete + safe copy
- AssetsPanel: asset delete now requires an in-app two-step confirm (tile-button turns into "Delete?" + cancel, auto-resets after 4s or on click-elsewhere) instead of deleting with no confirmation at all. - AssetsPanel: copyUrl uses a new copyToClipboard() helper that tries the async Clipboard API and falls back to a hidden-textarea execCommand copy in non-secure contexts, surfacing a visible "Copy failed" state instead of silently doing nothing. - SitesmithModal: replaced window.confirm(...) for "Clear chat" with the same in-app two-step confirm pattern -- no native dialogs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,36 @@
|
|||||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
import { useAssets } from '../../hooks/useAssets';
|
import { useAssets } from '../../hooks/useAssets';
|
||||||
|
import { clickableProps } from '../../utils/a11y';
|
||||||
|
import { copyToClipboard } from '../../utils/clipboard';
|
||||||
|
|
||||||
|
/** How long the "Delete?" confirm state stays armed before auto-resetting. */
|
||||||
|
const DELETE_CONFIRM_TIMEOUT_MS = 4000;
|
||||||
|
|
||||||
export const AssetsPanel: React.FC = () => {
|
export const AssetsPanel: React.FC = () => {
|
||||||
const { assets, loading, error, loadAssets, uploadAsset, deleteAsset } = useAssets();
|
const { assets, loading, error, loadAssets, uploadAsset, deleteAsset } = useAssets();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
const [copiedUrl, setCopiedUrl] = useState<string | null>(null);
|
const [copiedUrl, setCopiedUrl] = useState<string | null>(null);
|
||||||
|
const [copyErrorUrl, setCopyErrorUrl] = useState<string | null>(null);
|
||||||
|
const [confirmDeleteName, setConfirmDeleteName] = useState<string | null>(null);
|
||||||
|
const deleteConfirmTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (deleteConfirmTimeoutRef.current) clearTimeout(deleteConfirmTimeoutRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetDeleteConfirm = useCallback(() => {
|
||||||
|
if (deleteConfirmTimeoutRef.current) clearTimeout(deleteConfirmTimeoutRef.current);
|
||||||
|
setConfirmDeleteName(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const armDeleteConfirm = useCallback((name: string) => {
|
||||||
|
setConfirmDeleteName(name);
|
||||||
|
if (deleteConfirmTimeoutRef.current) clearTimeout(deleteConfirmTimeoutRef.current);
|
||||||
|
deleteConfirmTimeoutRef.current = setTimeout(() => setConfirmDeleteName(null), DELETE_CONFIRM_TIMEOUT_MS);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadAssets();
|
loadAssets();
|
||||||
@@ -37,11 +62,17 @@ export const AssetsPanel: React.FC = () => {
|
|||||||
setIsDragOver(false);
|
setIsDragOver(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const copyUrl = useCallback((url: string) => {
|
const copyUrl = useCallback(async (url: string) => {
|
||||||
navigator.clipboard.writeText(url).then(() => {
|
const ok = await copyToClipboard(url);
|
||||||
|
if (ok) {
|
||||||
|
setCopyErrorUrl(null);
|
||||||
setCopiedUrl(url);
|
setCopiedUrl(url);
|
||||||
setTimeout(() => setCopiedUrl(null), 2000);
|
setTimeout(() => setCopiedUrl(null), 2000);
|
||||||
});
|
} else {
|
||||||
|
setCopiedUrl(null);
|
||||||
|
setCopyErrorUrl(url);
|
||||||
|
setTimeout(() => setCopyErrorUrl(null), 2500);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const isImage = (type: string) =>
|
const isImage = (type: string) =>
|
||||||
@@ -133,19 +164,30 @@ export const AssetsPanel: React.FC = () => {
|
|||||||
gap: 6,
|
gap: 6,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{assets.map((asset) => (
|
{assets.map((asset) => {
|
||||||
|
const isConfirmingDelete = confirmDeleteName === asset.name;
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={asset.name}
|
key={asset.name}
|
||||||
|
{...clickableProps(() => {
|
||||||
|
// While armed, clicking anywhere on the tile (other than the
|
||||||
|
// delete control itself) counts as "click elsewhere" and
|
||||||
|
// disarms the confirm instead of copying the URL.
|
||||||
|
if (isConfirmingDelete) {
|
||||||
|
resetDeleteConfirm();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
copyUrl(asset.url);
|
||||||
|
})}
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
background: 'var(--color-bg-elevated)',
|
background: 'var(--color-bg-elevated)',
|
||||||
border: '1px solid var(--color-border)',
|
border: '1px solid var(--color-border)',
|
||||||
borderRadius: 'var(--radius-md)',
|
borderRadius: 'var(--radius-md)',
|
||||||
overflow: 'hidden',
|
overflow: 'visible',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'border-color var(--transition-fast)',
|
transition: 'border-color var(--transition-fast)',
|
||||||
}}
|
}}
|
||||||
onClick={() => copyUrl(asset.url)}
|
|
||||||
title={`Click to copy URL: ${asset.url}`}
|
title={`Click to copy URL: ${asset.url}`}
|
||||||
>
|
>
|
||||||
{/* Thumbnail */}
|
{/* Thumbnail */}
|
||||||
@@ -158,6 +200,7 @@ export const AssetsPanel: React.FC = () => {
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
background: 'var(--color-bg-base)',
|
background: 'var(--color-bg-base)',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
|
borderRadius: 'var(--radius-md) var(--radius-md) 0 0',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isImage(asset.type) ? (
|
{isImage(asset.type) ? (
|
||||||
@@ -183,52 +226,114 @@ export const AssetsPanel: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Name */}
|
{/* Name / status */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: '4px 6px',
|
padding: '4px 6px',
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
color: 'var(--color-text-muted)',
|
color: copyErrorUrl === asset.url ? 'var(--color-danger)' : 'var(--color-text-muted)',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{copiedUrl === asset.url ? 'Copied!' : asset.name}
|
{copyErrorUrl === asset.url ? 'Copy failed' : copiedUrl === asset.url ? 'Copied!' : asset.name}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Delete button */}
|
{/* Delete control: idle icon, or an in-app two-step confirm */}
|
||||||
<button
|
{isConfirmingDelete ? (
|
||||||
onClick={(e) => {
|
<div
|
||||||
e.stopPropagation();
|
style={{
|
||||||
deleteAsset(asset.name);
|
position: 'absolute',
|
||||||
}}
|
top: 4,
|
||||||
title="Delete asset"
|
right: 4,
|
||||||
style={{
|
display: 'flex',
|
||||||
position: 'absolute',
|
gap: 3,
|
||||||
top: 4,
|
zIndex: 1,
|
||||||
right: 4,
|
}}
|
||||||
width: 20,
|
>
|
||||||
height: 20,
|
<button
|
||||||
display: 'flex',
|
onClick={(e) => {
|
||||||
alignItems: 'center',
|
e.stopPropagation();
|
||||||
justifyContent: 'center',
|
resetDeleteConfirm();
|
||||||
fontSize: 10,
|
deleteAsset(asset.name);
|
||||||
color: '#fff',
|
}}
|
||||||
background: 'rgba(0,0,0,0.6)',
|
title="Click again to permanently delete"
|
||||||
border: 'none',
|
aria-label={`Confirm delete ${asset.name}`}
|
||||||
borderRadius: '50%',
|
autoFocus
|
||||||
cursor: 'pointer',
|
style={{
|
||||||
opacity: 0.7,
|
padding: '2px 6px',
|
||||||
transition: 'opacity var(--transition-fast)',
|
fontSize: 9,
|
||||||
}}
|
fontWeight: 700,
|
||||||
onMouseEnter={(e) => { (e.target as HTMLElement).style.opacity = '1'; }}
|
color: '#fff',
|
||||||
onMouseLeave={(e) => { (e.target as HTMLElement).style.opacity = '0.7'; }}
|
background: 'var(--color-danger)',
|
||||||
>
|
border: 'none',
|
||||||
✕
|
borderRadius: 'var(--radius-sm)',
|
||||||
</button>
|
cursor: 'pointer',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete?
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
resetDeleteConfirm();
|
||||||
|
}}
|
||||||
|
title="Cancel"
|
||||||
|
aria-label={`Cancel delete ${asset.name}`}
|
||||||
|
style={{
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: 9,
|
||||||
|
color: '#fff',
|
||||||
|
background: 'rgba(0,0,0,0.6)',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '50%',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
armDeleteConfirm(asset.name);
|
||||||
|
}}
|
||||||
|
title="Delete asset"
|
||||||
|
aria-label={`Delete ${asset.name}`}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 4,
|
||||||
|
right: 4,
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: 10,
|
||||||
|
color: '#fff',
|
||||||
|
background: 'rgba(0,0,0,0.6)',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '50%',
|
||||||
|
cursor: 'pointer',
|
||||||
|
opacity: 0.7,
|
||||||
|
transition: 'opacity var(--transition-fast)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { (e.target as HTMLElement).style.opacity = '1'; }}
|
||||||
|
onMouseLeave={(e) => { (e.target as HTMLElement).style.opacity = '0.7'; }}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Loading indicator */}
|
{/* Loading indicator */}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { useEditor } from '@craftjs/core';
|
import { useEditor } from '@craftjs/core';
|
||||||
import { useEditorConfig } from '../../state/EditorConfigContext';
|
import { useEditorConfig } from '../../state/EditorConfigContext';
|
||||||
import { useSitesmith } from '../../hooks/useSitesmith';
|
import { useSitesmith } from '../../hooks/useSitesmith';
|
||||||
@@ -28,6 +28,14 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [pendingReplace, setPendingReplace] = useState<SitesmithResponse | null>(null);
|
const [pendingReplace, setPendingReplace] = useState<SitesmithResponse | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [confirmClearChat, setConfirmClearChat] = useState(false);
|
||||||
|
const clearChatTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (clearChatTimeoutRef.current) clearTimeout(clearChatTimeoutRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const canChat = summary && (summary.status === 'OK_BONUS' || summary.status === 'OK_MONTHLY');
|
const canChat = summary && (summary.status === 'OK_BONUS' || summary.status === 'OK_MONTHLY');
|
||||||
|
|
||||||
@@ -67,6 +75,23 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
|
|||||||
if (!r.ok) setError(r.message || 'Apply failed');
|
if (!r.ok) setError(r.message || 'Apply failed');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const cancelClearChat = () => {
|
||||||
|
if (clearChatTimeoutRef.current) clearTimeout(clearChatTimeoutRef.current);
|
||||||
|
setConfirmClearChat(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearChatClick = async () => {
|
||||||
|
if (!confirmClearChat) {
|
||||||
|
setConfirmClearChat(true);
|
||||||
|
if (clearChatTimeoutRef.current) clearTimeout(clearChatTimeoutRef.current);
|
||||||
|
clearChatTimeoutRef.current = setTimeout(() => setConfirmClearChat(false), 4000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cancelClearChat();
|
||||||
|
const r = await clearHistory();
|
||||||
|
if (!r.ok) setError(r.error || 'Failed to clear history');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div role="dialog" aria-modal="true" style={overlay}>
|
<div role="dialog" aria-modal="true" style={overlay}>
|
||||||
<div style={panel}>
|
<div style={panel}>
|
||||||
@@ -80,17 +105,29 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
|
|||||||
)}
|
)}
|
||||||
<div style={{ flex: 1 }} />
|
<div style={{ flex: 1 }} />
|
||||||
{messages.length > 0 && (
|
{messages.length > 0 && (
|
||||||
<button
|
confirmClearChat ? (
|
||||||
onClick={async () => {
|
<>
|
||||||
if (!window.confirm('Clear all Sitesmith chat history for this site? The canvas is unaffected.')) return;
|
<button
|
||||||
const r = await clearHistory();
|
onClick={handleClearChatClick}
|
||||||
if (!r.ok) setError(r.error || 'Failed to clear history');
|
style={{ ...clearBtn, background: '#b91c1c', color: '#fff', borderColor: '#b91c1c' }}
|
||||||
}}
|
title="Click again to permanently clear chat history"
|
||||||
style={clearBtn}
|
autoFocus
|
||||||
title="Clear chat history"
|
>
|
||||||
>
|
Confirm clear?
|
||||||
Clear chat
|
</button>
|
||||||
</button>
|
<button onClick={cancelClearChat} style={clearBtn} title="Cancel">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleClearChatClick}
|
||||||
|
style={clearBtn}
|
||||||
|
title="Clear all Sitesmith chat history for this site (canvas is unaffected)"
|
||||||
|
>
|
||||||
|
Clear chat
|
||||||
|
</button>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
<button onClick={onClose} aria-label="Close" style={closeBtn}>✕</button>
|
<button onClick={onClose} aria-label="Close" style={closeBtn}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { copyToClipboard } from './clipboard';
|
||||||
|
|
||||||
|
describe('copyToClipboard', () => {
|
||||||
|
const originalClipboard = (navigator as any).clipboard;
|
||||||
|
const originalIsSecureContext = window.isSecureContext;
|
||||||
|
const originalExecCommand = (document as any).execCommand;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Object.defineProperty(navigator, 'clipboard', { value: originalClipboard, configurable: true });
|
||||||
|
Object.defineProperty(window, 'isSecureContext', { value: originalIsSecureContext, configurable: true });
|
||||||
|
(document as any).execCommand = originalExecCommand;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses navigator.clipboard.writeText when available in a secure context', async () => {
|
||||||
|
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||||
|
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
|
||||||
|
Object.defineProperty(window, 'isSecureContext', { value: true, configurable: true });
|
||||||
|
|
||||||
|
const ok = await copyToClipboard('hello');
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
expect(writeText).toHaveBeenCalledWith('hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to execCommand when clipboard API is unavailable', async () => {
|
||||||
|
Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true });
|
||||||
|
Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true });
|
||||||
|
(document as any).execCommand = vi.fn().mockReturnValue(true);
|
||||||
|
|
||||||
|
const ok = await copyToClipboard('hello');
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
expect((document as any).execCommand).toHaveBeenCalledWith('copy');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to execCommand when clipboard.writeText rejects', async () => {
|
||||||
|
const writeText = vi.fn().mockRejectedValue(new Error('denied'));
|
||||||
|
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
|
||||||
|
Object.defineProperty(window, 'isSecureContext', { value: true, configurable: true });
|
||||||
|
(document as any).execCommand = vi.fn().mockReturnValue(true);
|
||||||
|
|
||||||
|
const ok = await copyToClipboard('hello');
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
expect((document as any).execCommand).toHaveBeenCalledWith('copy');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false when both clipboard API and execCommand fail', async () => {
|
||||||
|
Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true });
|
||||||
|
Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true });
|
||||||
|
(document as any).execCommand = vi.fn().mockReturnValue(false);
|
||||||
|
|
||||||
|
const ok = await copyToClipboard('hello');
|
||||||
|
expect(ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false and does not throw when execCommand itself throws', async () => {
|
||||||
|
Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true });
|
||||||
|
Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true });
|
||||||
|
(document as any).execCommand = vi.fn().mockImplementation(() => { throw new Error('nope'); });
|
||||||
|
|
||||||
|
const ok = await copyToClipboard('hello');
|
||||||
|
expect(ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Copies `text` to the clipboard, preferring the async Clipboard API and
|
||||||
|
* falling back to a hidden-textarea + `document.execCommand('copy')` when
|
||||||
|
* the Clipboard API is unavailable or unusable (e.g. a non-secure-context
|
||||||
|
* local/dev origin, or a browser/permission that rejects the write).
|
||||||
|
* Never throws -- resolves `false` on failure so callers can show a
|
||||||
|
* visible error state instead of silently doing nothing.
|
||||||
|
*/
|
||||||
|
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to the legacy fallback below.
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.setAttribute('readonly', '');
|
||||||
|
textarea.style.position = 'fixed';
|
||||||
|
textarea.style.top = '-9999px';
|
||||||
|
textarea.style.left = '-9999px';
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
textarea.setSelectionRange(0, textarea.value.length);
|
||||||
|
const ok = document.execCommand('copy');
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
return ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user