Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
119 changes: 119 additions & 0 deletions src/components/Collection/Episode/EditEpisodeTitleModal.tsx
Original file line number Diff line number Diff line change
@@ -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 = () => {
Comment thread
hidden4003 marked this conversation as resolved.
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 (
<ModalPanel
show={show}
onRequestClose={isPending ? undefined : onRequestClose}
size="sm"
header="Edit Episode Title"
footer={
<div className="flex justify-between gap-x-3">
<Button
buttonType="secondary"
buttonSize="normal"
onClick={handleReset}
disabled={isPending || !episode.HasCustomName}
>
Reset to Default
</Button>
<div className="flex gap-x-3">
<Button buttonType="secondary" buttonSize="normal" onClick={onRequestClose} disabled={isPending}>
Cancel
</Button>
<Button
buttonType="primary"
buttonSize="normal"
onClick={handleSave}
disabled={isPending || !title}
loading={isPending}
>
Save
</Button>
</div>
</div>
}
>
<div className="flex flex-col gap-y-4">
<Input
id="episode-title"
type="text"
label="Title"
value={title}
onChange={event => setTitle(event.target.value)}
autoFocus
/>
{episode.AniDB && episode.AniDB.Titles.length > 0 && (
<div className="flex max-h-64 cursor-pointer flex-col gap-y-2 overflow-y-auto rounded-lg border border-panel-border bg-panel-input p-6">
{episode.AniDB.Titles.map(episodeTitle => (
<div
className="flex justify-between transition-colors last:border-none hover:text-panel-text-primary"
key={episodeTitle.Name + episodeTitle.Language}
onClick={() => setTitle(episodeTitle.Name)}
>
<div>{episodeTitle.Name}</div>
<div className="shrink-0 text-right uppercase">{episodeTitle.Language}</div>
</div>
))}
</div>
)}
</div>
</ModalPanel>
);
};

export default EditEpisodeTitleModal;
19 changes: 16 additions & 3 deletions src/components/Collection/Episode/EpisodeDetails.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 }) => (
<div className="flex max-h-52 grow flex-col gap-y-4 overflow-hidden">
<div className="flex justify-between font-semibold">
<div className="opacity-65">
Expand All @@ -33,8 +41,13 @@ const EpisodeDetails = ({ episode }: { episode: EpisodeType }) => (
)}
</div>

<div className="-mt-4 text-xl font-semibold">
<div className="-mt-4 flex items-center gap-x-2 text-xl font-semibold">
{episode.Name}
{onEditTitle && (
<Button onClick={onEditTitle} tooltip="Edit Title">
<Icon className="text-panel-icon-action" path={mdiPencilCircleOutline} size={0.8} />
</Button>
)}
</div>

<div className="flex flex-wrap items-center gap-x-3 text-sm font-semibold">
Expand Down
11 changes: 10 additions & 1 deletion src/components/Collection/Episode/EpisodeSummary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -90,6 +91,7 @@ const EpisodeSummary = (
const { backdrop } = useOutletContext<SeriesContextType>();
const thumbnail = useEpisodeThumbnail(episode, backdrop);
const [open, toggleOpen] = useToggle(false);
const [titleModalOpen, toggleTitleModalOpen] = useToggle(false);
const episodeId = episode.IDs.ID ?? 0;

const episodeFilesQuery = useEpisodeFilesQuery(
Expand Down Expand Up @@ -158,8 +160,15 @@ const EpisodeSummary = (
</div>
</div>
</BackgroundImagePlaceholderDiv>
<EpisodeDetails episode={episode} />
<EpisodeDetails episode={episode} onEditTitle={toggleTitleModalOpen} />
</div>
<EditEpisodeTitleModal
show={titleModalOpen}
onRequestClose={toggleTitleModalOpen}
episode={episode}
seriesId={seriesId}
nextUp={nextUp}
/>
{anidbSeriesId && episode.Size > 0 && (
<>
<div
Expand Down
17 changes: 17 additions & 0 deletions src/core/react-query/episode/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,23 @@ export const useHideEpisodeMutation = (seriesId?: number, nextUp = false) =>
},
});

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) =>
Expand Down
1 change: 1 addition & 0 deletions src/core/types/api/episode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type EpisodeType = {
Movies: TmdbMovieType[];
};
IsHidden: boolean;
HasCustomName: boolean;
Files?: FileType[];
};

Expand Down