put delete actions behind a kebab menu to prevent accidental deletion

Replace the always-visible delete buttons on the movie detail modal and
TV series header with a ⋮ kebab menu. Selecting "Delete" from the menu
shows an inline confirmation banner before any action is taken.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Garret Patti
2026-04-05 12:09:05 -04:00
parent e8b317f99d
commit b254907cca
2 changed files with 175 additions and 112 deletions

View File

@@ -15,14 +15,19 @@ interface Props {
export default function MovieDetailModal({ movie, libraryId, onClose, onTagsChanged, onDeleted }: Props) { export default function MovieDetailModal({ movie, libraryId, onClose, onTagsChanged, onDeleted }: Props) {
const overlayRef = useRef<HTMLDivElement>(null) const overlayRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const [playing, setPlaying] = useState(false) const [playing, setPlaying] = useState(false)
const [menuOpen, setMenuOpen] = useState(false)
const [confirming, setConfirming] = useState(false) const [confirming, setConfirming] = useState(false)
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const cancelRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => { useEffect(() => {
const handleKey = (e: KeyboardEvent) => { const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose() if (e.key === 'Escape') {
if (menuOpen) { setMenuOpen(false); return }
if (confirming) { setConfirming(false); return }
onClose()
}
} }
document.addEventListener('keydown', handleKey) document.addEventListener('keydown', handleKey)
document.body.style.overflow = 'hidden' document.body.style.overflow = 'hidden'
@@ -30,7 +35,19 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onTagsChan
document.removeEventListener('keydown', handleKey) document.removeEventListener('keydown', handleKey)
document.body.style.overflow = '' document.body.style.overflow = ''
} }
}, [onClose]) }, [onClose, menuOpen, confirming])
// Close menu on outside click
useEffect(() => {
if (!menuOpen) return
const handler = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuOpen(false)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [menuOpen])
const handleOverlayClick = (e: React.MouseEvent) => { const handleOverlayClick = (e: React.MouseEvent) => {
if (e.target === overlayRef.current) onClose() if (e.target === overlayRef.current) onClose()
@@ -38,13 +55,7 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onTagsChan
const videoUrl = `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(movie.videoPath)}` const videoUrl = `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(movie.videoPath)}`
const handleDeleteClick = () => { const handleConfirmDelete = () => {
if (!confirming) {
setConfirming(true)
cancelRef.current = setTimeout(() => setConfirming(false), 4000)
return
}
if (cancelRef.current) clearTimeout(cancelRef.current)
setDeleting(true) setDeleting(true)
fetch(`/api/movies?libraryId=${encodeURIComponent(libraryId)}&movieId=${encodeURIComponent(movie.id)}`, { fetch(`/api/movies?libraryId=${encodeURIComponent(libraryId)}&movieId=${encodeURIComponent(movie.id)}`, {
method: 'DELETE', method: 'DELETE',
@@ -53,11 +64,6 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onTagsChan
.catch(() => setDeleting(false)) .catch(() => setDeleting(false))
} }
const handleCancelDelete = () => {
if (cancelRef.current) clearTimeout(cancelRef.current)
setConfirming(false)
}
if (playing) { if (playing) {
return <VideoPlayerModal url={videoUrl} name={movie.title} onClose={() => setPlaying(false)} /> return <VideoPlayerModal url={videoUrl} name={movie.title} onClose={() => setPlaying(false)} />
} }
@@ -103,8 +109,9 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onTagsChan
{/* Info */} {/* Info */}
<div className="p-5"> <div className="p-5">
<div className="flex items-start justify-between gap-3 mb-1"> {/* Title row with kebab menu */}
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}> <div className="flex items-start gap-2 mb-1">
<h2 className="text-lg font-semibold flex-1 min-w-0" style={{ color: 'var(--text-primary)' }}>
{movie.title} {movie.title}
</h2> </h2>
{movie.year && ( {movie.year && (
@@ -112,6 +119,35 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onTagsChan
{movie.year} {movie.year}
</span> </span>
)} )}
{/* Kebab menu */}
<div className="relative flex-shrink-0" ref={menuRef}>
<button
onClick={() => { setMenuOpen((o) => !o); setConfirming(false) }}
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'transparent' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
aria-label="More options"
>
</button>
{menuOpen && (
<div
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
<button
onClick={() => { setMenuOpen(false); setConfirming(true) }}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
style={{ color: '#fca5a5' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
Delete movie
</button>
</div>
)}
</div>
</div> </div>
{/* Meta row */} {/* Meta row */}
@@ -141,6 +177,37 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onTagsChan
</p> </p>
)} )}
{/* Confirmation banner */}
{confirming && (
<div
className="flex items-center gap-3 mb-4 px-3 py-2.5 rounded-lg text-sm"
style={{ backgroundColor: '#7f1d1d33', border: '1px solid #7f1d1d' }}
>
<p className="flex-1 text-xs" style={{ color: '#fca5a5' }}>
Permanently delete this movie and all its files?
</p>
<button
onClick={() => setConfirming(false)}
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
>
Cancel
</button>
<button
onClick={handleConfirmDelete}
disabled={deleting}
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors disabled:opacity-50"
style={{ backgroundColor: '#7f1d1d', color: '#fca5a5' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = '#991b1b')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = '#7f1d1d')}
>
{deleting ? 'Deleting…' : 'Yes, delete'}
</button>
</div>
)}
{/* Play button */} {/* Play button */}
<button <button
onClick={() => setPlaying(true)} onClick={() => setPlaying(true)}
@@ -160,47 +227,6 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onTagsChan
</p> </p>
<TagSelector mediaKey={`${libraryId}:${movie.id}`} onTagsChanged={onTagsChanged} /> <TagSelector mediaKey={`${libraryId}:${movie.id}`} onTagsChanged={onTagsChanged} />
</div> </div>
{/* Delete */}
<div className="mt-4 pt-4 flex items-center gap-2" style={{ borderTop: '1px solid var(--border)' }}>
{confirming && (
<p className="text-xs flex-1" style={{ color: 'var(--text-secondary)' }}>
This will permanently delete the folder and all its contents.
</p>
)}
{confirming && (
<button
onClick={handleCancelDelete}
className="text-xs px-2.5 py-1.5 rounded-lg flex-shrink-0"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
>
Cancel
</button>
)}
<button
onClick={handleDeleteClick}
disabled={deleting}
className="text-xs px-2.5 py-1.5 rounded-lg flex-shrink-0 transition-colors disabled:opacity-50 ml-auto"
style={{
backgroundColor: confirming ? '#7f1d1d' : 'var(--border)',
color: confirming ? '#fca5a5' : 'var(--text-secondary)',
}}
onMouseEnter={(e) => {
if (!confirming) {
;(e.currentTarget as HTMLElement).style.backgroundColor = '#7f1d1d'
;(e.currentTarget as HTMLElement).style.color = '#fca5a5'
}
}}
onMouseLeave={(e) => {
if (!confirming) {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}
}}
>
{deleting ? 'Deleting…' : confirming ? 'Confirm delete?' : 'Delete movie'}
</button>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useEffect, useState, useCallback } from 'react' import { useEffect, useRef, useState, useCallback } from 'react'
import type { TvSeries, TvSeason, TvEpisode } from '@/types' import type { TvSeries, TvSeason, TvEpisode } from '@/types'
import FilterPanel from '@/components/FilterPanel' import FilterPanel from '@/components/FilterPanel'
import VideoPlayerModal from '@/components/mixed/VideoPlayerModal' import VideoPlayerModal from '@/components/mixed/VideoPlayerModal'
@@ -26,8 +26,10 @@ export default function TvView({ libraryId }: Props) {
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set()) const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
const [assignments, setAssignments] = useState<Record<string, string[]>>({}) const [assignments, setAssignments] = useState<Record<string, string[]>>({})
const [filterRefreshKey, setFilterRefreshKey] = useState(0) const [filterRefreshKey, setFilterRefreshKey] = useState(0)
const [menuOpen, setMenuOpen] = useState(false)
const [confirming, setConfirming] = useState(false) const [confirming, setConfirming] = useState(false)
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
const toggleTag = (tagId: string) => const toggleTag = (tagId: string) =>
setSelectedTagIds((prev) => { setSelectedTagIds((prev) => {
@@ -80,10 +82,23 @@ export default function TvView({ libraryId }: Props) {
.catch(() => { setError('Failed to load episodes'); setLoading(false) }) .catch(() => { setError('Failed to load episodes'); setLoading(false) })
} }
// Close menu on outside click
useEffect(() => {
if (!menuOpen) return
const handler = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuOpen(false)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [menuOpen])
const goToSeries = () => { const goToSeries = () => {
setView('series') setView('series')
setSelectedSeries(null) setSelectedSeries(null)
setSelectedSeason(null) setSelectedSeason(null)
setMenuOpen(false)
setConfirming(false) setConfirming(false)
} }
@@ -95,11 +110,6 @@ export default function TvView({ libraryId }: Props) {
const handleDeleteSeries = () => { const handleDeleteSeries = () => {
if (!selectedSeries) return if (!selectedSeries) return
if (!confirming) {
setConfirming(true)
setTimeout(() => setConfirming(false), 4000)
return
}
setDeleting(true) setDeleting(true)
fetch( fetch(
`/api/tv?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(selectedSeries.id)}`, `/api/tv?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(selectedSeries.id)}`,
@@ -235,61 +245,88 @@ export default function TvView({ libraryId }: Props) {
{view === 'seasons' && selectedSeries && ( {view === 'seasons' && selectedSeries && (
<div> <div>
{/* Series info header */} {/* Series info header */}
<div className="flex items-start gap-4 mb-6 p-4 rounded-xl" style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}> <div className="mb-6 p-4 rounded-xl" style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}>
{selectedSeries.posterUrl && ( <div className="flex items-start gap-4">
// eslint-disable-next-line @next/next/no-img-element {selectedSeries.posterUrl && (
<img src={selectedSeries.posterUrl} alt={selectedSeries.title} className="w-16 rounded-lg object-cover flex-shrink-0" style={{ aspectRatio: '2/3' }} /> // eslint-disable-next-line @next/next/no-img-element
)} <img src={selectedSeries.posterUrl} alt={selectedSeries.title} className="w-16 rounded-lg object-cover flex-shrink-0" style={{ aspectRatio: '2/3' }} />
<div className="flex-1 min-w-0"> )}
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{selectedSeries.title}</h2> <div className="flex-1 min-w-0">
{(selectedSeries.year || selectedSeries.genres.length > 0) && ( <div className="flex items-start gap-2">
<div className="flex flex-wrap items-center gap-2 mt-1"> <h2 className="text-lg font-semibold flex-1 min-w-0" style={{ color: 'var(--text-primary)' }}>{selectedSeries.title}</h2>
{selectedSeries.year && <span className="text-xs" style={{ color: 'var(--text-secondary)' }}>{selectedSeries.year}</span>} {/* Kebab menu */}
{selectedSeries.genres.map((g) => ( <div className="relative flex-shrink-0" ref={menuRef}>
<span key={g} className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}>{g}</span> <button
))} onClick={() => { setMenuOpen((o) => !o); setConfirming(false) }}
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'transparent' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
aria-label="More options"
>
</button>
{menuOpen && (
<div
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
<button
onClick={() => { setMenuOpen(false); setConfirming(true) }}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
style={{ color: '#fca5a5' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
Delete series
</button>
</div>
)}
</div>
</div> </div>
)} {(selectedSeries.year || selectedSeries.genres.length > 0) && (
{selectedSeries.plot && ( <div className="flex flex-wrap items-center gap-2 mt-1">
<p className="text-sm mt-2 line-clamp-3" style={{ color: 'var(--text-secondary)' }}>{selectedSeries.plot}</p> {selectedSeries.year && <span className="text-xs" style={{ color: 'var(--text-secondary)' }}>{selectedSeries.year}</span>}
)} {selectedSeries.genres.map((g) => (
<span key={g} className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}>{g}</span>
))}
</div>
)}
{selectedSeries.plot && (
<p className="text-sm mt-2 line-clamp-3" style={{ color: 'var(--text-secondary)' }}>{selectedSeries.plot}</p>
)}
</div>
</div> </div>
{/* Delete series button */} {/* Confirmation banner */}
<div className="flex items-center gap-2 flex-shrink-0"> {confirming && (
{confirming && ( <div
className="flex items-center gap-3 mt-3 px-3 py-2.5 rounded-lg"
style={{ backgroundColor: '#7f1d1d33', border: '1px solid #7f1d1d' }}
>
<p className="flex-1 text-xs" style={{ color: '#fca5a5' }}>
Permanently delete this series and all its files?
</p>
<button <button
onClick={() => setConfirming(false)} onClick={() => setConfirming(false)}
className="text-xs px-2.5 py-1.5 rounded-lg" className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }} style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
> >
Cancel Cancel
</button> </button>
)} <button
<button onClick={handleDeleteSeries}
onClick={handleDeleteSeries} disabled={deleting}
disabled={deleting} className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors disabled:opacity-50"
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors disabled:opacity-50" style={{ backgroundColor: '#7f1d1d', color: '#fca5a5' }}
style={{ onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = '#991b1b')}
backgroundColor: confirming ? '#7f1d1d' : 'var(--border)', onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = '#7f1d1d')}
color: confirming ? '#fca5a5' : 'var(--text-secondary)', >
}} {deleting ? 'Deleting…' : 'Yes, delete'}
onMouseEnter={(e) => { </button>
if (!confirming) { </div>
;(e.currentTarget as HTMLElement).style.backgroundColor = '#7f1d1d' )}
;(e.currentTarget as HTMLElement).style.color = '#fca5a5'
}
}}
onMouseLeave={(e) => {
if (!confirming) {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}
}}
title={confirming ? 'Click again to permanently delete this series and all its files' : 'Delete this series'}
>
{deleting ? 'Deleting…' : confirming ? 'Confirm delete?' : 'Delete series'}
</button>
</div>
</div> </div>
{loading ? ( {loading ? (