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[];
};