From 8f53158cc3203da66cf009ba13e23fee67b1d383 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 8 Mar 2026 22:25:12 +0100 Subject: [PATCH] Add multiselect to the combo box --- front/public/translations/en.json | 4 +- front/src/primitives/combobox.tsx | 114 ++++++++++++++++---------- front/src/primitives/combobox.web.tsx | 54 +++++++----- front/src/primitives/modal.tsx | 2 +- front/src/ui/admin/videos-modal.tsx | 20 +++-- front/src/ui/details/header.tsx | 26 +++--- 6 files changed, 138 insertions(+), 82 deletions(-) diff --git a/front/public/translations/en.json b/front/public/translations/en.json index 02a90036..8b167e31 100644 --- a/front/public/translations/en.json +++ b/front/public/translations/en.json @@ -44,7 +44,9 @@ "nextUp": "Next up", "season": "Season {{number}}", "multiVideos": "Multiples video files available", - "videosCount": "{{number}} videos" + "videosCount": "{{number}} videos", + "videos-map": "Edit video mappings", + "videos-map-none": "No mapping" }, "browse": { "mediatypekey": { diff --git a/front/src/primitives/combobox.tsx b/front/src/primitives/combobox.tsx index 63197783..fd6a38f2 100644 --- a/front/src/primitives/combobox.tsx +++ b/front/src/primitives/combobox.tsx @@ -4,8 +4,8 @@ import Check from "@material-symbols/svg-400/rounded/check-fill.svg"; import Close from "@material-symbols/svg-400/rounded/close-fill.svg"; import ExpandMore from "@material-symbols/svg-400/rounded/keyboard_arrow_down-fill.svg"; import SearchIcon from "@material-symbols/svg-400/rounded/search-fill.svg"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { Pressable, TextInput, View } from "react-native"; +import { useMemo, useRef, useState } from "react"; +import { KeyboardAvoidingView, Pressable, TextInput, View } from "react-native"; import { type QueryIdentifier, useInfiniteFetch } from "~/query/query"; import { cn } from "~/utils"; import { Icon, IconButton } from "./icons"; @@ -13,43 +13,54 @@ import { PressableFeedback } from "./links"; import { Skeleton } from "./skeleton"; import { P } from "./text"; -const useDebounce = (value: T, delay: number): T => { - const [debounced, setDebounced] = useState(value); - useEffect(() => { - const timer = setTimeout(() => setDebounced(value), delay); - return () => clearTimeout(timer); - }, [value, delay]); - return debounced; +type ComboBoxSingleProps = { + multiple?: false; + value: Data | null; + values?: never; + onValueChange: (item: Data | null) => void; }; +type ComboBoxMultiProps = { + multiple: true; + value?: never; + values: Data[]; + onValueChange: (items: Data[]) => void; +}; + +type ComboBoxBaseProps = { + label: string; + searchPlaceholder?: string; + query: (search: string) => QueryIdentifier; + getKey: (item: Data) => string; + getLabel: (item: Data) => string; + getSmallLabel?: (item: Data) => string; + placeholderCount?: number; +}; + +export type ComboBoxProps = ComboBoxBaseProps & + (ComboBoxSingleProps | ComboBoxMultiProps); + export const ComboBox = ({ label, value, + values, onValueChange, query, getLabel, + getSmallLabel, getKey, - placeholder, + searchPlaceholder, placeholderCount = 4, -}: { - label: string; - value: Data | null; - onValueChange: (item: Data | null) => void; - query: (search: string) => QueryIdentifier; - getLabel: (item: Data) => string; - getKey: (item: Data) => string; - placeholder?: string; - placeholderCount?: number; -}) => { + multiple, +}: ComboBoxProps) => { const [isOpen, setOpen] = useState(false); const [search, setSearch] = useState(""); - const debouncedSearch = useDebounce(search, 300); const inputRef = useRef(null); - const currentQuery = query(debouncedSearch); const oldItems = useRef(undefined); - let { items, fetchNextPage, hasNextPage, isFetching } = - useInfiniteFetch(currentQuery); + let { items, fetchNextPage, hasNextPage, isFetching } = useInfiniteFetch( + query(search), + ); if (items) oldItems.current = items; items ??= oldItems.current; @@ -59,16 +70,10 @@ export const ComboBox = ({ return isFetching ? [...items, ...placeholders] : items; }, [items, isFetching, placeholderCount]); - const handleSelect = (item: Data) => { - onValueChange(item); - setOpen(false); - setSearch(""); - }; - - const handleClose = () => { - setOpen(false); - setSearch(""); - }; + const selectedKeys = useMemo(() => { + if (multiple) return new Set(values.map(getKey)); + return new Set(value !== null ? [getKey(value)] : []); + }, [value, values, multiple, getKey]); return ( <> @@ -83,7 +88,11 @@ export const ComboBox = ({ >

- {value ? getLabel(value) : (placeholder ?? label)} + {(multiple ? !values : !value) + ? label + : (multiple ? values : [value!]) + .map(getSmallLabel ?? getLabel) + .join(", ")}

({ {isOpen && ( { + setOpen(false); + setSearch(""); + }} tabIndex={-1} className="absolute inset-0 flex-1 bg-transparent" /> - ({ > { + setOpen(false); + setSearch(""); + }} className="hidden self-end xl:flex" /> ({ ref={inputRef} value={search} onChangeText={setSearch} - placeholder={placeholder ?? label} + placeholder={searchPlaceholder} autoFocus textAlignVertical="center" className="h-full flex-1 font-sans text-base text-slate-600 outline-0 dark:text-slate-400" @@ -137,8 +153,22 @@ export const ComboBox = ({ item ? ( handleSelect(item)} + selected={selectedKeys.has(getKey(item))} + onSelect={() => { + if (!multiple) { + onValueChange(item); + setOpen(false); + return; + } + + if (!selectedKeys.has(getKey(item))) { + onValueChange([...values, item]); + return; + } + onValueChange( + values.filter((v) => getKey(v) !== getKey(item)), + ); + }} /> ) : ( @@ -150,7 +180,7 @@ export const ComboBox = ({ onEndReachedThreshold={0.5} showsVerticalScrollIndicator={false} /> - + )} diff --git a/front/src/primitives/combobox.web.tsx b/front/src/primitives/combobox.web.tsx index 419ce6ee..f1f794b5 100644 --- a/front/src/primitives/combobox.web.tsx +++ b/front/src/primitives/combobox.web.tsx @@ -5,8 +5,9 @@ import SearchIcon from "@material-symbols/svg-400/rounded/search-fill.svg"; import * as Popover from "@radix-ui/react-popover"; import { useMemo, useRef, useState } from "react"; import { Platform, View } from "react-native"; -import { type QueryIdentifier, useInfiniteFetch } from "~/query/query"; +import { useInfiniteFetch } from "~/query/query"; import { cn } from "~/utils"; +import type { ComboBoxProps } from "./combobox"; import { Icon } from "./icons"; import { PressableFeedback } from "./links"; import { InternalTriger } from "./menu.web"; @@ -15,23 +16,17 @@ import { P } from "./text"; export const ComboBox = ({ label, + searchPlaceholder, value, + values, onValueChange, query, - getLabel, getKey, - placeholder, + getLabel, + getSmallLabel, placeholderCount = 4, -}: { - label: string; - value: Data | null; - onValueChange: (item: Data | null) => void; - query: (search: string) => QueryIdentifier; - getLabel: (item: Data) => string; - getKey: (item: Data) => string; - placeholder?: string; - placeholderCount?: number; -}) => { + multiple, +}: ComboBoxProps) => { const [isOpen, setOpen] = useState(false); const [search, setSearch] = useState(""); @@ -48,6 +43,11 @@ export const ComboBox = ({ return isFetching ? [...items, ...placeholders] : items; }, [items, isFetching, placeholderCount]); + const selectedKeys = useMemo(() => { + if (multiple) return new Set(values.map(getKey)); + return new Set(value !== null ? [getKey(value as Data)] : []); + }, [value, values, multiple, getKey]); + return ( ({ >

- {value ? getLabel(value) : (placeholder ?? label)} + {(multiple ? !values : !value) + ? label + : (multiple ? values : [value!]) + .map(getSmallLabel ?? getLabel) + .join(", ")}

({ type="text" value={search} onChange={(e) => setSearch(e.target.value)} - placeholder={placeholder ?? label} + placeholder={searchPlaceholder} // biome-ignore lint/a11y/noAutofocus: combobox search should auto-focus on open autoFocus className={cn( @@ -115,14 +119,22 @@ export const ComboBox = ({ item ? ( - ((item: Data) => { + selected={selectedKeys.has(getKey(item))} + onSelect={() => { + if (!multiple) { onValueChange(item); setOpen(false); - setSearch(""); - })(item) - } + return; + } + + if (!selectedKeys.has(getKey(item))) { + onValueChange([...values, item]); + return; + } + onValueChange( + values.filter((v) => getKey(v) !== getKey(item)), + ); + }} /> ) : ( diff --git a/front/src/primitives/modal.tsx b/front/src/primitives/modal.tsx index 0d8f7171..6cc13f54 100644 --- a/front/src/primitives/modal.tsx +++ b/front/src/primitives/modal.tsx @@ -37,7 +37,7 @@ export const Modal = ({ e.preventDefault()} > diff --git a/front/src/ui/admin/videos-modal.tsx b/front/src/ui/admin/videos-modal.tsx index 4debee4f..a92b9719 100644 --- a/front/src/ui/admin/videos-modal.tsx +++ b/front/src/ui/admin/videos-modal.tsx @@ -1,15 +1,19 @@ +import { useTranslation } from "react-i18next"; import { View } from "react-native"; import { entryDisplayNumber } from "~/components/entries"; import { Entry, FullVideo } from "~/models"; import { ComboBox, Modal, P, Skeleton } from "~/primitives"; -import { InfiniteFetch, type QueryIdentifier } from "~/query"; +import { InfiniteFetch, type QueryIdentifier, useFetch } from "~/query"; import { useQueryState } from "~/utils"; +import { Header } from "../details/header"; export const VideosModal = () => { const [slug] = useQueryState("slug", undefined!); + const { data } = useFetch(Header.query("serie", slug)); + const { t } = useTranslation(); return ( - + {

{item.path}

entryDisplayNumber(x)).join(", ")} + multiple + label={t("show.videos-map-none")} + searchPlaceholder={t("navbar.search")} + values={item.entries} query={(q) => ({ parser: Entry, path: ["api", "series", slug, "entries"], @@ -28,9 +33,10 @@ export const VideosModal = () => { }, infinite: true, })} - getLabel={(x) => `${entryDisplayNumber(x)} - ${x.name}`} - onValueChange={(x) => {}} getKey={(x) => x.id} + getLabel={(x) => `${entryDisplayNumber(x)} - ${x.name}`} + getSmallLabel={entryDisplayNumber} + onValueChange={(x) => {}} />
)} diff --git a/front/src/ui/details/header.tsx b/front/src/ui/details/header.tsx index eb0be504..7ac007c2 100644 --- a/front/src/ui/details/header.tsx +++ b/front/src/ui/details/header.tsx @@ -3,6 +3,7 @@ import MoreHoriz from "@material-symbols/svg-400/rounded/more_horiz.svg"; import MovieInfo from "@material-symbols/svg-400/rounded/movie_info.svg"; import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg"; import Theaters from "@material-symbols/svg-400/rounded/theaters-fill.svg"; +import VideoLibrary from "@material-symbols/svg-400/rounded/video_library-fill.svg"; import { Fragment } from "react"; import { useTranslation } from "react-i18next"; import { View, type ViewProps } from "react-native"; @@ -116,16 +117,21 @@ const ButtonList = ({ /> )} - {/* {account?.isAdmin === true && ( */} - {/* <> */} - {/* {kind === "movie" &&
} */} - {/* metadataRefreshMutation.mutate()} */} - {/* /> */} - {/* */} - {/* )} */} + {account?.isAdmin === true && ( + <> + {kind === "movie" &&
} + + {/* metadataRefreshMutation.mutate()} */} + {/* /> */} + + )} )}