From 4b8dd8baeef599edd22c33ad9c52ec3610183a56 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 12 Jul 2026 14:51:00 -0700 Subject: [PATCH] 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) --- craft/src/panels/left/AssetsPanel.tsx | 185 ++++++++++++++---- craft/src/panels/sitesmith/SitesmithModal.tsx | 61 ++++-- craft/src/utils/clipboard.test.ts | 64 ++++++ craft/src/utils/clipboard.ts | 35 ++++ 4 files changed, 293 insertions(+), 52 deletions(-) create mode 100644 craft/src/utils/clipboard.test.ts create mode 100644 craft/src/utils/clipboard.ts diff --git a/craft/src/panels/left/AssetsPanel.tsx b/craft/src/panels/left/AssetsPanel.tsx index db99040..1229881 100644 --- a/craft/src/panels/left/AssetsPanel.tsx +++ b/craft/src/panels/left/AssetsPanel.tsx @@ -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(null); const [isDragOver, setIsDragOver] = useState(false); const [copiedUrl, setCopiedUrl] = useState(null); + const [copyErrorUrl, setCopyErrorUrl] = useState(null); + const [confirmDeleteName, setConfirmDeleteName] = useState(null); + const deleteConfirmTimeoutRef = useRef | 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 (
{ + // 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 = () => { )}
- {/* Name */} + {/* Name / status */}
- {copiedUrl === asset.url ? 'Copied!' : asset.name} + {copyErrorUrl === asset.url ? 'Copy failed' : copiedUrl === asset.url ? 'Copied!' : asset.name}
- {/* Delete button */} - + {/* Delete control: idle icon, or an in-app two-step confirm */} + {isConfirmingDelete ? ( +
+ + +
+ ) : ( + + )} - ))} + ); + })} {/* Loading indicator */} diff --git a/craft/src/panels/sitesmith/SitesmithModal.tsx b/craft/src/panels/sitesmith/SitesmithModal.tsx index 6de0f1f..6f3ea80 100644 --- a/craft/src/panels/sitesmith/SitesmithModal.tsx +++ b/craft/src/panels/sitesmith/SitesmithModal.tsx @@ -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 = ({ onClose, target }) => { const [busy, setBusy] = useState(false); const [pendingReplace, setPendingReplace] = useState(null); const [error, setError] = useState(null); + const [confirmClearChat, setConfirmClearChat] = useState(false); + const clearChatTimeoutRef = useRef | 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 = ({ 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 (
@@ -80,17 +105,29 @@ export const SitesmithModal: React.FC = ({ onClose, target }) => { )}
{messages.length > 0 && ( - + confirmClearChat ? ( + <> + + + + ) : ( + + ) )}
diff --git a/craft/src/utils/clipboard.test.ts b/craft/src/utils/clipboard.test.ts new file mode 100644 index 0000000..ced01c2 --- /dev/null +++ b/craft/src/utils/clipboard.test.ts @@ -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); + }); +}); diff --git a/craft/src/utils/clipboard.ts b/craft/src/utils/clipboard.ts new file mode 100644 index 0000000..3b0b4ba --- /dev/null +++ b/craft/src/utils/clipboard.ts @@ -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 { + 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; + } +}