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:
2026-07-12 14:51:00 -07:00
parent 46ebd253f3
commit 4b8dd8baee
4 changed files with 293 additions and 52 deletions
+145 -40
View File
@@ -1,11 +1,36 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
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 = () => {
const { assets, loading, error, loadAssets, uploadAsset, deleteAsset } = useAssets();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragOver, setIsDragOver] = useState(false);
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(() => {
loadAssets();
@@ -37,11 +62,17 @@ export const AssetsPanel: React.FC = () => {
setIsDragOver(false);
}, []);
const copyUrl = useCallback((url: string) => {
navigator.clipboard.writeText(url).then(() => {
const copyUrl = useCallback(async (url: string) => {
const ok = await copyToClipboard(url);
if (ok) {
setCopyErrorUrl(null);
setCopiedUrl(url);
setTimeout(() => setCopiedUrl(null), 2000);
});
} else {
setCopiedUrl(null);
setCopyErrorUrl(url);
setTimeout(() => setCopyErrorUrl(null), 2500);
}
}, []);
const isImage = (type: string) =>
@@ -133,19 +164,30 @@ export const AssetsPanel: React.FC = () => {
gap: 6,
}}
>
{assets.map((asset) => (
{assets.map((asset) => {
const isConfirmingDelete = confirmDeleteName === asset.name;
return (
<div
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={{
position: 'relative',
background: 'var(--color-bg-elevated)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
overflow: 'hidden',
overflow: 'visible',
cursor: 'pointer',
transition: 'border-color var(--transition-fast)',
}}
onClick={() => copyUrl(asset.url)}
title={`Click to copy URL: ${asset.url}`}
>
{/* Thumbnail */}
@@ -158,6 +200,7 @@ export const AssetsPanel: React.FC = () => {
justifyContent: 'center',
background: 'var(--color-bg-base)',
overflow: 'hidden',
borderRadius: 'var(--radius-md) var(--radius-md) 0 0',
}}
>
{isImage(asset.type) ? (
@@ -183,52 +226,114 @@ export const AssetsPanel: React.FC = () => {
)}
</div>
{/* Name */}
{/* Name / status */}
<div
style={{
padding: '4px 6px',
fontSize: 10,
color: 'var(--color-text-muted)',
color: copyErrorUrl === asset.url ? 'var(--color-danger)' : 'var(--color-text-muted)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{copiedUrl === asset.url ? 'Copied!' : asset.name}
{copyErrorUrl === asset.url ? 'Copy failed' : copiedUrl === asset.url ? 'Copied!' : asset.name}
</div>
{/* Delete button */}
<button
onClick={(e) => {
e.stopPropagation();
deleteAsset(asset.name);
}}
title="Delete asset"
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'; }}
>
&#10005;
</button>
{/* Delete control: idle icon, or an in-app two-step confirm */}
{isConfirmingDelete ? (
<div
style={{
position: 'absolute',
top: 4,
right: 4,
display: 'flex',
gap: 3,
zIndex: 1,
}}
>
<button
onClick={(e) => {
e.stopPropagation();
resetDeleteConfirm();
deleteAsset(asset.name);
}}
title="Click again to permanently delete"
aria-label={`Confirm delete ${asset.name}`}
autoFocus
style={{
padding: '2px 6px',
fontSize: 9,
fontWeight: 700,
color: '#fff',
background: 'var(--color-danger)',
border: 'none',
borderRadius: 'var(--radius-sm)',
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',
}}
>
&#10005;
</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'; }}
>
&#10005;
</button>
)}
</div>
))}
);
})}
</div>
{/* Loading indicator */}
+49 -12
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { useEditor } from '@craftjs/core';
import { useEditorConfig } from '../../state/EditorConfigContext';
import { useSitesmith } from '../../hooks/useSitesmith';
@@ -28,6 +28,14 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
const [busy, setBusy] = useState(false);
const [pendingReplace, setPendingReplace] = useState<SitesmithResponse | 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');
@@ -67,6 +75,23 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
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 (
<div role="dialog" aria-modal="true" style={overlay}>
<div style={panel}>
@@ -80,17 +105,29 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
)}
<div style={{ flex: 1 }} />
{messages.length > 0 && (
<button
onClick={async () => {
if (!window.confirm('Clear all Sitesmith chat history for this site? The canvas is unaffected.')) return;
const r = await clearHistory();
if (!r.ok) setError(r.error || 'Failed to clear history');
}}
style={clearBtn}
title="Clear chat history"
>
Clear chat
</button>
confirmClearChat ? (
<>
<button
onClick={handleClearChatClick}
style={{ ...clearBtn, background: '#b91c1c', color: '#fff', borderColor: '#b91c1c' }}
title="Click again to permanently clear chat history"
autoFocus
>
Confirm clear?
</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>
</div>