From b254907ccad82ee710fbeacbd6c1501718934fa3 Mon Sep 17 00:00:00 2001 From: Garret Patti <42485635+garretpatti@users.noreply.github.com> Date: Sun, 5 Apr 2026 12:09:05 -0400 Subject: [PATCH] put delete actions behind a kebab menu to prevent accidental deletion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/components/movies/MovieDetailModal.tsx | 142 +++++++++++--------- src/components/tv/TvView.tsx | 145 +++++++++++++-------- 2 files changed, 175 insertions(+), 112 deletions(-) diff --git a/src/components/movies/MovieDetailModal.tsx b/src/components/movies/MovieDetailModal.tsx index 3c4b780..e111ff9 100644 --- a/src/components/movies/MovieDetailModal.tsx +++ b/src/components/movies/MovieDetailModal.tsx @@ -15,14 +15,19 @@ interface Props { export default function MovieDetailModal({ movie, libraryId, onClose, onTagsChanged, onDeleted }: Props) { const overlayRef = useRef(null) + const menuRef = useRef(null) const [playing, setPlaying] = useState(false) + const [menuOpen, setMenuOpen] = useState(false) const [confirming, setConfirming] = useState(false) const [deleting, setDeleting] = useState(false) - const cancelRef = useRef | null>(null) useEffect(() => { 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.body.style.overflow = 'hidden' @@ -30,7 +35,19 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onTagsChan document.removeEventListener('keydown', handleKey) 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) => { 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 handleDeleteClick = () => { - if (!confirming) { - setConfirming(true) - cancelRef.current = setTimeout(() => setConfirming(false), 4000) - return - } - if (cancelRef.current) clearTimeout(cancelRef.current) + const handleConfirmDelete = () => { setDeleting(true) fetch(`/api/movies?libraryId=${encodeURIComponent(libraryId)}&movieId=${encodeURIComponent(movie.id)}`, { method: 'DELETE', @@ -53,11 +64,6 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onTagsChan .catch(() => setDeleting(false)) } - const handleCancelDelete = () => { - if (cancelRef.current) clearTimeout(cancelRef.current) - setConfirming(false) - } - if (playing) { return setPlaying(false)} /> } @@ -103,8 +109,9 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onTagsChan {/* Info */}
-
-

+ {/* Title row with kebab menu */} +
+

{movie.title}

{movie.year && ( @@ -112,6 +119,35 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onTagsChan {movie.year} )} + {/* Kebab menu */} +
+ + {menuOpen && ( +
+ +
+ )} +
{/* Meta row */} @@ -141,6 +177,37 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onTagsChan

)} + {/* Confirmation banner */} + {confirming && ( +
+

+ Permanently delete this movie and all its files? +

+ + +
+ )} + {/* Play button */}

- - {/* Delete */} -
- {confirming && ( -

- This will permanently delete the folder and all its contents. -

- )} - {confirming && ( - - )} - -
diff --git a/src/components/tv/TvView.tsx b/src/components/tv/TvView.tsx index 6e45a45..58b5c04 100644 --- a/src/components/tv/TvView.tsx +++ b/src/components/tv/TvView.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useState, useCallback } from 'react' +import { useEffect, useRef, useState, useCallback } from 'react' import type { TvSeries, TvSeason, TvEpisode } from '@/types' import FilterPanel from '@/components/FilterPanel' import VideoPlayerModal from '@/components/mixed/VideoPlayerModal' @@ -26,8 +26,10 @@ export default function TvView({ libraryId }: Props) { const [selectedTagIds, setSelectedTagIds] = useState>(new Set()) const [assignments, setAssignments] = useState>({}) const [filterRefreshKey, setFilterRefreshKey] = useState(0) + const [menuOpen, setMenuOpen] = useState(false) const [confirming, setConfirming] = useState(false) const [deleting, setDeleting] = useState(false) + const menuRef = useRef(null) const toggleTag = (tagId: string) => setSelectedTagIds((prev) => { @@ -80,10 +82,23 @@ export default function TvView({ libraryId }: Props) { .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 = () => { setView('series') setSelectedSeries(null) setSelectedSeason(null) + setMenuOpen(false) setConfirming(false) } @@ -95,11 +110,6 @@ export default function TvView({ libraryId }: Props) { const handleDeleteSeries = () => { if (!selectedSeries) return - if (!confirming) { - setConfirming(true) - setTimeout(() => setConfirming(false), 4000) - return - } setDeleting(true) fetch( `/api/tv?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(selectedSeries.id)}`, @@ -235,61 +245,88 @@ export default function TvView({ libraryId }: Props) { {view === 'seasons' && selectedSeries && (
{/* Series info header */} -
- {selectedSeries.posterUrl && ( - // eslint-disable-next-line @next/next/no-img-element - {selectedSeries.title} - )} -
-

{selectedSeries.title}

- {(selectedSeries.year || selectedSeries.genres.length > 0) && ( -
- {selectedSeries.year && {selectedSeries.year}} - {selectedSeries.genres.map((g) => ( - {g} - ))} +
+
+ {selectedSeries.posterUrl && ( + // eslint-disable-next-line @next/next/no-img-element + {selectedSeries.title} + )} +
+
+

{selectedSeries.title}

+ {/* Kebab menu */} +
+ + {menuOpen && ( +
+ +
+ )} +
- )} - {selectedSeries.plot && ( -

{selectedSeries.plot}

- )} + {(selectedSeries.year || selectedSeries.genres.length > 0) && ( +
+ {selectedSeries.year && {selectedSeries.year}} + {selectedSeries.genres.map((g) => ( + {g} + ))} +
+ )} + {selectedSeries.plot && ( +

{selectedSeries.plot}

+ )} +
- {/* Delete series button */} -
- {confirming && ( + {/* Confirmation banner */} + {confirming && ( +
+

+ Permanently delete this series and all its files? +

- )} - -
+ +
+ )}
{loading ? (