Split videos-modal code

This commit is contained in:
Zoe Roux 2026-03-11 12:39:51 +01:00
parent 8f82a03989
commit 58deb07b9c
No known key found for this signature in database
9 changed files with 351 additions and 260 deletions

View File

@ -3,6 +3,7 @@ import {
desc,
eq,
gt,
inArray,
isNotNull,
lt,
max,
@ -11,8 +12,7 @@ import {
or,
type SQL,
sql,
inArray,
WithSubquery,
type WithSubquery,
} from "drizzle-orm";
import { alias } from "drizzle-orm/pg-core";
import { Elysia, t } from "elysia";
@ -33,7 +33,6 @@ import {
jsonbBuildObject,
jsonbObjectAgg,
sqlarr,
unnest,
} from "~/db/utils";
import type { Entry } from "~/models/entry";
import { KError } from "~/models/error";
@ -82,7 +81,7 @@ const videoSort = Sort(
],
},
{
default: ["entry", "path"],
default: ["path"],
tablePk: videos.pk,
},
);

View File

@ -28,7 +28,8 @@
"suspicious": {
"noExplicitAny": "off",
"noArrayIndexKey": "off",
"noTemplateCurlyInString": "off"
"noTemplateCurlyInString": "off",
"noDuplicateCustomProperties": "off"
},
"security": {
"noDangerouslySetInnerHtml": "off"
@ -44,16 +45,8 @@
"useSortedClasses": {
"level": "warn",
"options": {
"attributes": [
"classList"
],
"functions": [
"clsx",
"cva",
"cn",
"tw",
"tw.*"
]
"attributes": ["classList"],
"functions": ["clsx", "cva", "cn", "tw", "tw.*"]
}
}
}

View File

@ -45,13 +45,17 @@
"season": "Season {{number}}",
"multiVideos": "Multiples video files available",
"videosCount": "{{number}} videos",
"videos-map": "Edit video mappings",
"videos-map-none": "NONE",
"videos-map-add": "Add another video",
"videos-map-delete": "Remove video from serie",
"videos-map-validate": "Validate guess and add video to serie",
"videos-map-no-guess": "This video was guessed to be part of this serie but we don't know which episode",
"videos-map-related": "Added videos related to the title {{title}}"
"videos-map": "Edit video mappings"
},
"videos-map": {
"none": "NONE",
"add": "Add another video",
"delete": "Remove video from serie",
"validate": "Validate guess and add video to serie",
"no-guess": "This video was guessed to be part of this serie but we don't know which episode",
"related": "Added videos related to the title {{title}}",
"sort-path": "Path",
"sort-entry": "Episode order"
},
"browse": {
"mediatypekey": {

View File

@ -1,9 +1,5 @@
import type { ComponentProps, ComponentType, Ref } from "react";
import {
type Falsy,
type PressableProps,
View,
} from "react-native";
import { type Falsy, type PressableProps, View } from "react-native";
import { cn } from "~/utils";
import { Icon } from "./icons";
import { PressableFeedback } from "./links";

View File

@ -1,233 +0,0 @@
import Check from "@material-symbols/svg-400/rounded/check-fill.svg";
import Close from "@material-symbols/svg-400/rounded/close-fill.svg";
import Question from "@material-symbols/svg-400/rounded/question_mark-fill.svg";
import LibraryAdd from "@material-symbols/svg-400/rounded/library_add-fill.svg";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { entryDisplayNumber } from "~/components/entries";
import { Entry, FullVideo, type Page } from "~/models";
import {
Button,
ComboBox,
IconButton,
Modal,
P,
Skeleton,
tooltip,
} from "~/primitives";
import {
InfiniteFetch,
type QueryIdentifier,
useFetch,
useMutation,
} from "~/query";
import { useQueryState } from "~/utils";
import { Header } from "../details/header";
import { uniqBy } from "~/utils";
export const VideosModal = () => {
const [slug] = useQueryState<string>("slug", undefined!);
const { data } = useFetch(Header.query("serie", slug));
const { t } = useTranslation();
const [titles, setTitles] = useState<string[]>([]);
const { mutateAsync: editLinks } = useMutation({
method: "PUT",
path: ["api", "videos", "link"],
compute: ({
video,
entries,
guess = false,
}: {
video: string;
entries: Omit<Entry, "href" | "progress" | "videos">[];
guess?: boolean;
}) => ({
body: [
{
id: video,
for: entries.map((x) =>
guess && x.kind === "episode"
? {
serie: slug,
// @ts-expect-error: idk why it couldn't match x as an episode
season: x.seasonNumber,
// @ts-expect-error: idk why it couldn't match x as an episode
episode: x.episodeNumber,
}
: { slug: x.slug },
),
},
],
}),
invalidate: ["api", "series", slug],
optimisticKey: VideosModal.query(slug, null),
optimistic: (params, prev?: { pages: Page<FullVideo>[] }) => ({
...prev!,
pages: prev!.pages.map((p) => ({
...p,
items: p!.items.map((x) => {
if (x.id !== params.video) return x;
return { ...x, entries: params.entries };
}) as FullVideo[],
})),
}),
});
return (
<Modal title={data?.name ?? t("misc.loading")} scroll={false}>
{[...titles].map((title) => (
<View
key={title}
className="m-2 flex-row items-center justify-between rounded bg-card px-6"
>
<P>{t("show.videos-map-related", { title })}</P>
<IconButton
icon={Close}
onPress={() => {
setTitles(titles.filter((x) => x !== title));
}}
{...tooltip(t("misc.cancel"))}
/>
</View>
))}
<InfiniteFetch
query={VideosModal.query(slug, titles)}
layout={{ layout: "vertical", gap: 8, numColumns: 1, size: 48 }}
Render={({ item }) => {
const saved = item.entries.length;
const guess = !saved
? uniqBy(
item.guess.episodes.map(
(x) =>
({
kind: "episode",
id: `s${x.season}-e${x.episode}`,
seasonNumber: x.season,
episodeNumber: x.episode,
}) as Entry,
),
(x) => x.id,
)
: [];
return (
<View
className="mx-6 h-12 flex-row items-center justify-between hover:bg-card"
style={!saved && { opacity: 0.6 }}
>
<View className="flex-row items-center">
{saved ? (
<IconButton
icon={Close}
onPress={async () => {
if (!titles.includes(item.guess.title))
setTitles([...titles, item.guess.title]);
await editLinks({ video: item.id, entries: [] });
}}
{...tooltip(t("show.videos-map-delete"))}
/>
) : guess.length ? (
<IconButton
icon={Check}
onPress={async () => {
await editLinks({
video: item.id,
entries: guess,
guess: true,
});
}}
{...tooltip(t("show.videos-map-validate"))}
/>
) : (
<IconButton
disabled
icon={Question}
{...tooltip(t("show.videos-map-no-guess"))}
/>
)}
<P>{item.path}</P>
</View>
<ComboBox
multiple
label={t("show.videos-map-none")}
searchPlaceholder={t("navbar.search")}
values={saved ? item.entries : guess}
query={(q) => ({
parser: Entry,
path: ["api", "series", slug, "entries"],
params: {
query: q,
},
infinite: true,
})}
getKey={
saved
? (x) => x.id
: (x) =>
x.kind === "episode"
? `${x.seasonNumber}-${x.episodeNumber}`
: x.id
}
getLabel={(x) => `${entryDisplayNumber(x)} - ${x.name}`}
getSmallLabel={entryDisplayNumber}
onValueChange={async (entries) => {
if (!entries.length && !titles.includes(item.guess.title))
setTitles([...titles, item.guess.title]);
await editLinks({
video: item.id,
entries,
});
}}
/>
</View>
);
}}
Loader={() => <Skeleton />}
Footer={
<ComboBox
Trigger={(props) => (
<Button
icon={LibraryAdd}
text={t("show.videos-map-add")}
className="m-6 mt-10"
onPress={props.onPress ?? (props as any).onClick}
{...props}
/>
)}
searchPlaceholder={t("navbar.search")}
value={null}
query={(q) => ({
parser: FullVideo,
path: ["api", "videos"],
params: {
query: q,
sort: "path",
},
infinite: true,
})}
getKey={(x) => x.id}
getLabel={(x) => x.path}
onValueChange={(x) => {
if (x && !titles.includes(x.guess.title))
setTitles([...titles, x.guess.title]);
}}
/>
}
/>
</Modal>
);
};
VideosModal.query = (
slug: string,
titles: string[] | null,
): QueryIdentifier<FullVideo> => ({
parser: FullVideo,
path: ["api", "series", slug, "videos"],
params: {
sort: "path",
titles: titles ?? undefined,
},
infinite: true,
});

View File

@ -0,0 +1,92 @@
import Path from "@material-symbols/svg-400/rounded/conversion_path-fill.svg";
import LibraryAdd from "@material-symbols/svg-400/rounded/library_add-fill.svg";
import Sort from "@material-symbols/svg-400/rounded/sort.svg";
import Entry from "@material-symbols/svg-400/rounded/tv_next-fill.svg";
import { useTranslation } from "react-i18next";
import { FullVideo } from "~/models";
import {
Button,
ComboBox,
Icon,
Menu,
P,
PressableFeedback,
tooltip,
} from "~/primitives";
const sortModes = [
["path", Path],
["entry", Entry],
] as const;
export const SortMenu = ({
sort,
setSort,
}: {
sort: "path" | "entry";
setSort: (sort: "path" | "entry") => void;
}) => {
const { t } = useTranslation();
return (
<Menu
Trigger={(props) => (
<PressableFeedback
className="flex-row items-center"
{...tooltip(t("browse.sortby-tt"))}
{...props}
>
<Icon icon={Sort} className="mx-1" />
<P>{t(`videos-map.sort-${sort}`)}</P>
</PressableFeedback>
)}
>
{sortModes.map((x) => (
<Menu.Item
key={x[0]}
icon={x[1]}
label={t(`videos-map.sort-${x[0]}`)}
selected={sort === x[0]}
onSelect={() => setSort(x[0])}
/>
))}
</Menu>
);
};
export const AddVideoFooter = ({
addTitle,
}: {
addTitle: (title: string) => void;
}) => {
const { t } = useTranslation();
return (
<ComboBox
Trigger={(props) => (
<Button
icon={LibraryAdd}
text={t("videos-map.add")}
className="m-6 mt-10"
onPress={props.onPress ?? (props as any).onClick}
{...props}
/>
)}
searchPlaceholder={t("navbar.search")}
value={null}
query={(q) => ({
parser: FullVideo,
path: ["api", "videos"],
params: {
query: q,
sort: "path",
},
infinite: true,
})}
getKey={(x) => x.id}
getLabel={(x) => x.path}
onValueChange={(x) => {
if (x) addTitle(x.guess.title);
}}
/>
);
};

View File

@ -0,0 +1,130 @@
import Close from "@material-symbols/svg-400/rounded/close-fill.svg";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { type Entry, FullVideo, type Page } from "~/models";
import { IconButton, Modal, P, Skeleton, tooltip } from "~/primitives";
import {
InfiniteFetch,
type QueryIdentifier,
useFetch,
useMutation,
} from "~/query";
import { useQueryState } from "~/utils";
import { Header } from "../../details/header";
import { AddVideoFooter, SortMenu } from "./headers";
import { PathItem } from "./path-item";
export const useEditLinks = (
slug: string,
titles: string[],
sort: "path" | "entry",
) => {
const { mutateAsync } = useMutation({
method: "PUT",
path: ["api", "videos", "link"],
compute: ({
video,
entries,
guess = false,
}: {
video: string;
entries: Omit<Entry, "href" | "progress" | "videos">[];
guess?: boolean;
}) => ({
body: [
{
id: video,
for: entries.map((x) =>
guess && x.kind === "episode"
? {
serie: slug,
// @ts-expect-error: idk why it couldn't match x as an episode
season: x.seasonNumber,
// @ts-expect-error: idk why it couldn't match x as an episode
episode: x.episodeNumber,
}
: { slug: x.slug },
),
},
],
}),
invalidate: ["api", "series", slug],
optimisticKey: VideosModal.query(slug, titles, sort),
optimistic: (params, prev?: { pages: Page<FullVideo>[] }) => ({
...prev!,
pages: prev!.pages.map((p) => ({
...p,
items: p!.items.map((x) => {
if (x.id !== params.video) return x;
return { ...x, entries: params.entries };
}) as FullVideo[],
})),
}),
});
return mutateAsync;
};
export const VideosModal = () => {
const [slug] = useQueryState<string>("slug", undefined!);
const { data } = useFetch(Header.query("serie", slug));
const { t } = useTranslation();
const [titles, setTitles] = useState<string[]>([]);
const [sort, setSort] = useState<"entry" | "path">("path");
const editLinks = useEditLinks(slug, titles, sort);
const addTitle = (x: string) => {
if (!titles.includes(x)) setTitles([...titles, x]);
};
return (
<Modal title={data?.name ?? t("misc.loading")} scroll={false}>
{[...titles].map((title) => (
<View
key={title}
className="m-2 flex-row items-center justify-between rounded bg-card px-6"
>
<P>{t("videos-map.related", { title })}</P>
<IconButton
icon={Close}
onPress={() => {
setTitles(titles.filter((x) => x !== title));
}}
{...tooltip(t("misc.cancel"))}
/>
</View>
))}
<View className="mx-6 mb-6 flex-row items-center">
<SortMenu sort={sort} setSort={setSort} />
</View>
<InfiniteFetch
query={VideosModal.query(slug, titles, sort)}
layout={{ layout: "vertical", gap: 8, numColumns: 1, size: 48 }}
Render={({ item }) => (
<PathItem
item={item}
serieSlug={slug}
addTitle={addTitle}
editLinks={editLinks}
/>
)}
Loader={() => <Skeleton />}
Footer={<AddVideoFooter addTitle={addTitle} />}
/>
</Modal>
);
};
VideosModal.query = (
slug: string,
titles: string[],
sort: "path" | "entry",
): QueryIdentifier<FullVideo> => ({
parser: FullVideo,
path: ["api", "series", slug, "videos"],
params: {
sort: sort === "entry" ? ["entry", "path"] : sort,
titles: titles,
},
infinite: true,
});

View File

@ -0,0 +1 @@
export {};

View File

@ -0,0 +1,109 @@
import Check from "@material-symbols/svg-400/rounded/check-fill.svg";
import Close from "@material-symbols/svg-400/rounded/close-fill.svg";
import Question from "@material-symbols/svg-400/rounded/question_mark-fill.svg";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { entryDisplayNumber } from "~/components/entries";
import { Entry, type FullVideo } from "~/models";
import { ComboBox, IconButton, P, tooltip } from "~/primitives";
import { uniqBy } from "~/utils";
import type { useEditLinks } from ".";
export const PathItem = ({
item,
serieSlug,
addTitle,
editLinks,
}: {
item: FullVideo;
serieSlug: string;
addTitle: (title: string) => void;
editLinks: ReturnType<typeof useEditLinks>;
}) => {
const { t } = useTranslation();
const saved = item.entries.length;
const guess = !saved
? uniqBy(
item.guess.episodes.map(
(x) =>
({
kind: "episode",
id: `s${x.season}-e${x.episode}`,
seasonNumber: x.season,
episodeNumber: x.episode,
}) as Entry,
),
(x) => x.id,
)
: [];
return (
<View
className="mx-6 h-12 flex-row items-center justify-between hover:bg-card"
style={!saved && { opacity: 0.6 }}
>
<View className="flex-row items-center">
{saved ? (
<IconButton
icon={Close}
onPress={async () => {
addTitle(item.guess.title);
await editLinks({ video: item.id, entries: [] });
}}
{...tooltip(t("videos-map.delete"))}
/>
) : guess.length ? (
<IconButton
icon={Check}
onPress={async () => {
await editLinks({
video: item.id,
entries: guess,
guess: true,
});
}}
{...tooltip(t("videos-map.validate"))}
/>
) : (
<IconButton
disabled
icon={Question}
{...tooltip(t("videos-map.no-guess"))}
/>
)}
<P>{item.path}</P>
</View>
<ComboBox
multiple
label={t("videos-map.none")}
searchPlaceholder={t("navbar.search")}
values={saved ? item.entries : guess}
query={(q) => ({
parser: Entry,
path: ["api", "series", serieSlug, "entries"],
params: {
query: q,
},
infinite: true,
})}
getKey={
saved
? (x) => x.id
: (x) =>
x.kind === "episode"
? `${x.seasonNumber}-${x.episodeNumber}`
: x.id
}
getLabel={(x) => `${entryDisplayNumber(x)} - ${x.name}`}
getSmallLabel={entryDisplayNumber}
onValueChange={async (entries) => {
if (!entries.length) addTitle(item.guess.title);
await editLinks({
video: item.id,
entries,
});
}}
/>
</View>
);
};