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 { 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'; }}
|
||||
>
|
||||
✕
|
||||
</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',
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</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>
|
||||
|
||||
{/* Loading indicator */}
|
||||
|
||||
Reference in New Issue
Block a user