diff --git a/AGENTS.md b/AGENTS.md index ff49400c8..fc03b90b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,6 +104,7 @@ This project uses the **React Compiler** (via `@rolldown/plugin-babel`). The com - **`format-thousands` → `formatThousand` from `@/core/util`** — use for number formatting with thousands separators. Do not use `.toLocaleString()` or string concatenation. - **`fast-json-patch`** — use for JSON Patch (RFC 6902) operations. Do not write custom diff/patch logic for API settings updates. - **`classnames` (imported as `cx`)** — use for conditional CSS class joining. Do not construct class strings with template literals or string concatenation. +- **Modal hotkeys (`react-hotkeys-hook`):** guard `useHotkeys('escape', ...)` with the same pending/loading check used for `ModalPanel`'s `onRequestClose` — otherwise Escape can close a modal mid-save. Add `enableOnFormTags: true` to `useHotkeys('enter', ...)` when the modal has a focused text input, or the shortcut silently never fires (the library ignores form-tag targets by default). ## Specifications diff --git a/src/components/Collection/Episode/EditEpisodeTitleModal.tsx b/src/components/Collection/Episode/EditEpisodeTitleModal.tsx new file mode 100644 index 000000000..22f5c065e --- /dev/null +++ b/src/components/Collection/Episode/EditEpisodeTitleModal.tsx @@ -0,0 +1,119 @@ +import { useEffect, useState } from 'react'; +import { useHotkeys } from 'react-hotkeys-hook'; + +import Button from '@/components/Input/Button'; +import Input from '@/components/Input/Input'; +import ModalPanel from '@/components/Panels/ModalPanel'; +import { useOverrideEpisodeTitleMutation } from '@/core/react-query/episode/mutations'; +import toast from '@/core/toast'; +import useToggleModalKeybinds from '@/hooks/useToggleModalKeybinds'; + +import type { EpisodeType } from '@/core/types/api/episode'; + +type Props = { + show: boolean; + onRequestClose: () => void; + episode: EpisodeType; + seriesId: number; + nextUp?: boolean; +}; + +const EditEpisodeTitleModal = ({ episode, nextUp, onRequestClose, seriesId, show }: Props) => { + const [title, setTitle] = useState(''); + + const episodeId = episode.IDs.ID; + const { isPending, mutate: overrideTitle } = useOverrideEpisodeTitleMutation(episodeId, seriesId, nextUp); + + useEffect(() => { + if (show) setTitle(episode.Name); + }, [episode.Name, show]); + + const handleSave = () => { + if (isPending) return; + overrideTitle(title, { + onSuccess: () => { + toast.success('Episode title updated successfully!'); + onRequestClose(); + }, + onError: () => toast.error('Episode title could not be updated!'), + }); + }; + + const handleReset = () => { + if (isPending) return; + overrideTitle('', { + onSuccess: () => { + toast.success('Episode title reset to default!'); + onRequestClose(); + }, + onError: () => toast.error('Episode title could not be reset!'), + }); + }; + + useToggleModalKeybinds(show, 'modal'); + useToggleModalKeybinds(!show, 'primary'); + useHotkeys('escape', () => !isPending && onRequestClose(), { scopes: 'modal' }); + useHotkeys('enter', handleSave, { scopes: 'modal', enableOnFormTags: true }); + + return ( + + +
+ + +
+ + } + > +
+ setTitle(event.target.value)} + autoFocus + /> + {episode.AniDB && episode.AniDB.Titles.length > 0 && ( +
+ {episode.AniDB.Titles.map(episodeTitle => ( +
setTitle(episodeTitle.Name)} + > +
{episodeTitle.Name}
+
{episodeTitle.Language}
+
+ ))} +
+ )} +
+
+ ); +}; + +export default EditEpisodeTitleModal; diff --git a/src/components/Collection/Episode/EpisodeDetails.tsx b/src/components/Collection/Episode/EpisodeDetails.tsx index 00669b82a..80c90b3bc 100644 --- a/src/components/Collection/Episode/EpisodeDetails.tsx +++ b/src/components/Collection/Episode/EpisodeDetails.tsx @@ -1,7 +1,15 @@ -import { mdiCalendarMonthOutline, mdiClipboardOutline, mdiClockOutline, mdiOpenInNew, mdiStarHalfFull } from '@mdi/js'; +import { + mdiCalendarMonthOutline, + mdiClipboardOutline, + mdiClockOutline, + mdiOpenInNew, + mdiPencilCircleOutline, + mdiStarHalfFull, +} from '@mdi/js'; import { Icon } from '@mdi/react'; import { toNumber } from 'lodash'; +import Button from '@/components/Input/Button'; import { convertTimeSpanToMs, copyToClipboard, dayjs, getAnidbEpisodeLink } from '@/core/util'; import type { EpisodeType } from '@/core/types/api/episode'; @@ -16,7 +24,7 @@ const handleCopyToClipboard = (id: string) => { copyToClipboard(id, 'Shoko Episode ID').catch(console.error); }; -const EpisodeDetails = ({ episode }: { episode: EpisodeType }) => ( +const EpisodeDetails = ({ episode, onEditTitle }: { episode: EpisodeType, onEditTitle?: () => void }) => (
@@ -33,8 +41,13 @@ const EpisodeDetails = ({ episode }: { episode: EpisodeType }) => ( )}
-
+
{episode.Name} + {onEditTitle && ( + + )}
diff --git a/src/components/Collection/Episode/EpisodeSummary.tsx b/src/components/Collection/Episode/EpisodeSummary.tsx index 0b12f0308..32ad27d1c 100644 --- a/src/components/Collection/Episode/EpisodeSummary.tsx +++ b/src/components/Collection/Episode/EpisodeSummary.tsx @@ -18,6 +18,7 @@ import { useHideEpisodeMutation, useWatchEpisodeMutation } from '@/core/react-qu import { useEpisodeFilesQuery } from '@/core/react-query/episode/queries'; import useEpisodeThumbnail from '@/hooks/useEpisodeThumbnail'; +import EditEpisodeTitleModal from './EditEpisodeTitleModal'; import EpisodeDetails from './EpisodeDetails'; import EpisodeFiles from './EpisodeFiles'; @@ -90,6 +91,7 @@ const EpisodeSummary = ( const { backdrop } = useOutletContext(); const thumbnail = useEpisodeThumbnail(episode, backdrop); const [open, toggleOpen] = useToggle(false); + const [titleModalOpen, toggleTitleModalOpen] = useToggle(false); const episodeId = episode.IDs.ID ?? 0; const episodeFilesQuery = useEpisodeFilesQuery( @@ -158,8 +160,15 @@ const EpisodeSummary = (
- +
+ {anidbSeriesId && episode.Size > 0 && ( <>
}, }); +export const useOverrideEpisodeTitleMutation = (episodeId: number, seriesId?: number, nextUp = false) => + useMutation({ + mutationFn: (Title: string) => axios.post(`Episode/${episodeId}/OverrideTitle`, { Title }), + onSuccess: () => { + if (!seriesId) return; + + invalidateQueries(['series', seriesId, 'data']); + + if (nextUp) { + invalidateQueries(['series', seriesId, 'next-up']); + return; + } + + invalidateQueries(['series', seriesId, 'episodes']); + }, + }); + export const useWatchEpisodeMutation = (seriesId: number, pageNumber?: number, nextUp = false) => useMutation({ mutationFn: ({ episodeId, watched }: WatchEpisodeRequestType) => diff --git a/src/core/types/api/episode.ts b/src/core/types/api/episode.ts index 1c006f8e4..bfad67c1e 100644 --- a/src/core/types/api/episode.ts +++ b/src/core/types/api/episode.ts @@ -17,6 +17,7 @@ export type EpisodeType = { Movies: TmdbMovieType[]; }; IsHidden: boolean; + HasCustomName: boolean; Files?: FileType[]; };