From 2c297ebca927005c36b8669028e7ed684a19c4ee Mon Sep 17 00:00:00 2001 From: Vasyl Vdovychenko Date: Sat, 23 May 2026 12:27:15 -0400 Subject: [PATCH] fix(library): replace browser confirm() with inline two-stage delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two complaints from the UI: - The trash button in the action row had a loud red-bordered circle visual, drawing more attention than the actual primary actions. - Clicking it triggered the native browser confirm() popup — looks like an alien dialog, breaks the in-app feel. Mirrors the existing VocabularyPage delete pattern: idle styling is neutral (matches the neighbour icon buttons), first click arms the action — button flips to a red-filled pill with an inline "Confirm?" label — second click within 3 s actually deletes. The 3 s timer reverts the arm if the user does nothing, and the timer is cleared on unmount. Library refresh on delete continues to rely on the pre-existing emitDataChanges(['user-books', 'shelves']) + LibraryPage's on-mount fetch and 5s polling — that path was already in place. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/src/pages/UserBookDetailPage.tsx | 30 ++++++++++++++--- apps/web/src/styles/books.css | 40 ++++++++++++++++++----- 2 files changed, 57 insertions(+), 13 deletions(-) diff --git a/apps/web/src/pages/UserBookDetailPage.tsx b/apps/web/src/pages/UserBookDetailPage.tsx index e615a1c3..97799768 100644 --- a/apps/web/src/pages/UserBookDetailPage.tsx +++ b/apps/web/src/pages/UserBookDetailPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo } from 'react' +import { useState, useEffect, useMemo, useRef } from 'react' import { useParams, Link, useNavigate } from 'react-router-dom' import { useAuth } from '../context/AuthContext' import { useLanguage } from '../context/LanguageContext' @@ -27,6 +27,12 @@ export function UserBookDetailPage() { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [deleting, setDeleting] = useState(false) + // Inline two-stage confirm — mirrors VocabularyPage's delete pattern. + // First click flips the icon button into a red "Confirm?" pill; a second + // click within 3 s actually deletes. Avoids the browser-native confirm() + // popup and the prior loud red-circle visual. + const [pendingDelete, setPendingDelete] = useState(false) + const pendingTimeoutRef = useRef(null) // Get saved progress from localStorage const savedProgress = useMemo((): SavedProgress | null => { @@ -93,8 +99,16 @@ export function UserBookDetailPage() { const handleDelete = async () => { if (!id || deleting) return - if (!confirm('Are you sure you want to delete this book?')) return + // First click: arm the confirm. Second click within 3 s actually deletes. + if (!pendingDelete) { + setPendingDelete(true) + if (pendingTimeoutRef.current) window.clearTimeout(pendingTimeoutRef.current) + pendingTimeoutRef.current = window.setTimeout(() => setPendingDelete(false), 3000) + return + } + + if (pendingTimeoutRef.current) window.clearTimeout(pendingTimeoutRef.current) setDeleting(true) try { await deleteUserBook(id) @@ -104,9 +118,16 @@ export function UserBookDetailPage() { } catch (err) { setError(err instanceof Error ? err.message : 'Failed to delete') setDeleting(false) + setPendingDelete(false) } } + // Clear the confirm timer on unmount so a stray state update can't fire + // after navigation away from this page. + useEffect(() => () => { + if (pendingTimeoutRef.current) window.clearTimeout(pendingTimeoutRef.current) + }, []) + if (!isAuthenticated) { return ( <> @@ -281,8 +302,8 @@ export function UserBookDetailPage() { type="button" onClick={handleDelete} disabled={deleting} - className="user-book-detail__delete-icon" - aria-label={deleting ? 'Deleting…' : 'Delete this book'} + className={`user-book-detail__delete-icon${pendingDelete ? ' user-book-detail__delete-icon--confirming' : ''}`} + aria-label={deleting ? 'Deleting…' : pendingDelete ? 'Click again to confirm delete' : 'Delete this book'} > + {pendingDelete && Confirm?} )} diff --git a/apps/web/src/styles/books.css b/apps/web/src/styles/books.css index 8cd9e87c..4b2b9261 100644 --- a/apps/web/src/styles/books.css +++ b/apps/web/src/styles/books.css @@ -4662,30 +4662,52 @@ html.dark .add-to-collection-button--icon[aria-label]::after { background: rgba(0, 0, 0, 0.04); } -/* Trash icon now lives in the primary action row next to Share/Add-to-collection. - Custom hover tooltip via aria-label ::after. */ +/* Trash icon in the primary action row. Idle styling matches the neighbour + icon buttons (Add-to-collection, Link, Share) — neutral border so the + destructive action doesn't shout. Two-stage confirm (see *--confirming + below) handles the danger emphasis only when actually arming the action. */ .user-book-detail__delete-icon { display: inline-flex; align-items: center; justify-content: center; - width: 36px; + gap: 6px; height: 36px; - border-radius: 50%; + min-width: 36px; + padding: 0 10px; + border-radius: 18px; background: transparent; - border: 1px solid #dc2626; - color: #dc2626; + border: 1px solid var(--color-border, #E8E2D9); + color: var(--color-text-muted, #6b6b6b); cursor: pointer; - padding: 0; position: relative; - transition: background 0.15s ease, border-color 0.15s ease; + transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; } .user-book-detail__delete-icon:hover:not(:disabled) { - background: rgba(220, 38, 38, 0.08); + color: #dc2626; + border-color: #dc2626; + background: rgba(220, 38, 38, 0.06); } .user-book-detail__delete-icon:disabled { opacity: 0.5; cursor: not-allowed; } +/* Armed state — second click within 3s actually deletes. Red filled pill + with inline "Confirm?" label, mirrors VocabularyPage's delete pattern. */ +.user-book-detail__delete-icon--confirming, +.user-book-detail__delete-icon--confirming:hover:not(:disabled) { + background: #dc2626; + border-color: #dc2626; + color: #fff; +} +.user-book-detail__delete-icon__confirm-label { + font-size: 13px; + font-weight: 600; + line-height: 1; +} +/* Tooltip is redundant once the confirm label is visible — hide it. */ +.user-book-detail__delete-icon--confirming[aria-label]::after { + display: none; +} .user-book-detail__delete-icon[aria-label]::after { content: attr(aria-label); position: absolute;