mirror of
https://github.com/zoriya/Kyoo.git
synced 2026-03-18 23:47:48 -04:00
Add multiselect to the combo box
This commit is contained in:
parent
86687f8802
commit
8f53158cc3
@ -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": {
|
||||
|
||||
@ -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 = <T,>(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<Data> = {
|
||||
multiple?: false;
|
||||
value: Data | null;
|
||||
values?: never;
|
||||
onValueChange: (item: Data | null) => void;
|
||||
};
|
||||
|
||||
type ComboBoxMultiProps<Data> = {
|
||||
multiple: true;
|
||||
value?: never;
|
||||
values: Data[];
|
||||
onValueChange: (items: Data[]) => void;
|
||||
};
|
||||
|
||||
type ComboBoxBaseProps<Data> = {
|
||||
label: string;
|
||||
searchPlaceholder?: string;
|
||||
query: (search: string) => QueryIdentifier<Data>;
|
||||
getKey: (item: Data) => string;
|
||||
getLabel: (item: Data) => string;
|
||||
getSmallLabel?: (item: Data) => string;
|
||||
placeholderCount?: number;
|
||||
};
|
||||
|
||||
export type ComboBoxProps<Data> = ComboBoxBaseProps<Data> &
|
||||
(ComboBoxSingleProps<Data> | ComboBoxMultiProps<Data>);
|
||||
|
||||
export const ComboBox = <Data,>({
|
||||
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<Data>;
|
||||
getLabel: (item: Data) => string;
|
||||
getKey: (item: Data) => string;
|
||||
placeholder?: string;
|
||||
placeholderCount?: number;
|
||||
}) => {
|
||||
multiple,
|
||||
}: ComboBoxProps<Data>) => {
|
||||
const [isOpen, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
|
||||
const currentQuery = query(debouncedSearch);
|
||||
const oldItems = useRef<Data[] | undefined>(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 = <Data,>({
|
||||
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 = <Data,>({
|
||||
>
|
||||
<View className="flex-row items-center px-6">
|
||||
<P className="text-center group-focus-within:text-slate-200 group-hover:text-slate-200">
|
||||
{value ? getLabel(value) : (placeholder ?? label)}
|
||||
{(multiple ? !values : !value)
|
||||
? label
|
||||
: (multiple ? values : [value!])
|
||||
.map(getSmallLabel ?? getLabel)
|
||||
.join(", ")}
|
||||
</P>
|
||||
<Icon
|
||||
icon={ExpandMore}
|
||||
@ -94,11 +103,15 @@ export const ComboBox = <Data,>({
|
||||
{isOpen && (
|
||||
<Portal>
|
||||
<Pressable
|
||||
onPress={handleClose}
|
||||
onPress={() => {
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
}}
|
||||
tabIndex={-1}
|
||||
className="absolute inset-0 flex-1 bg-transparent"
|
||||
/>
|
||||
<View
|
||||
<KeyboardAvoidingView
|
||||
behavior="padding"
|
||||
className={cn(
|
||||
"absolute bottom-0 w-full self-center bg-popover pb-safe sm:mx-12 sm:max-w-2xl",
|
||||
"mt-20 max-h-[80vh] rounded-t-4xl pt-8",
|
||||
@ -107,7 +120,10 @@ export const ComboBox = <Data,>({
|
||||
>
|
||||
<IconButton
|
||||
icon={Close}
|
||||
onPress={handleClose}
|
||||
onPress={() => {
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
}}
|
||||
className="hidden self-end xl:flex"
|
||||
/>
|
||||
<View
|
||||
@ -121,7 +137,7 @@ export const ComboBox = <Data,>({
|
||||
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 = <Data,>({
|
||||
item ? (
|
||||
<ComboBoxItem
|
||||
label={getLabel(item)}
|
||||
selected={value !== null && getKey(item) === getKey(value)}
|
||||
onSelect={() => 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)),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ComboBoxItemLoader />
|
||||
@ -150,7 +180,7 @@ export const ComboBox = <Data,>({
|
||||
onEndReachedThreshold={0.5}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</Portal>
|
||||
)}
|
||||
</>
|
||||
|
||||
@ -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 = <Data,>({
|
||||
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<Data>;
|
||||
getLabel: (item: Data) => string;
|
||||
getKey: (item: Data) => string;
|
||||
placeholder?: string;
|
||||
placeholderCount?: number;
|
||||
}) => {
|
||||
multiple,
|
||||
}: ComboBoxProps<Data>) => {
|
||||
const [isOpen, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
@ -48,6 +43,11 @@ export const ComboBox = <Data,>({
|
||||
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 (
|
||||
<Popover.Root
|
||||
open={isOpen}
|
||||
@ -67,7 +67,11 @@ export const ComboBox = <Data,>({
|
||||
>
|
||||
<View className="flex-row items-center px-6">
|
||||
<P className="text-center group-focus-within:text-slate-200 group-hover:text-slate-200">
|
||||
{value ? getLabel(value) : (placeholder ?? label)}
|
||||
{(multiple ? !values : !value)
|
||||
? label
|
||||
: (multiple ? values : [value!])
|
||||
.map(getSmallLabel ?? getLabel)
|
||||
.join(", ")}
|
||||
</P>
|
||||
<Icon
|
||||
icon={ExpandMore}
|
||||
@ -96,7 +100,7 @@ export const ComboBox = <Data,>({
|
||||
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 = <Data,>({
|
||||
item ? (
|
||||
<ComboBoxItem
|
||||
label={getLabel(item)}
|
||||
selected={value !== null && getKey(item) === getKey(value)}
|
||||
onSelect={() =>
|
||||
((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)),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ComboBoxItemLoader />
|
||||
|
||||
@ -37,7 +37,7 @@ export const Modal = ({
|
||||
<Pressable
|
||||
className={cn(
|
||||
"w-full max-w-3xl rounded-md bg-background",
|
||||
"max-h-[90vh] cursor-default overflow-hidden *:p-6",
|
||||
"max-h-[90vh] cursor-default! overflow-hidden *:p-6",
|
||||
)}
|
||||
onPress={(e) => e.preventDefault()}
|
||||
>
|
||||
|
||||
@ -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<string>("slug", undefined!);
|
||||
const { data } = useFetch(Header.query("serie", slug));
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Modal title="toto" scroll={false}>
|
||||
<Modal title={data?.name ?? t("misc.loading")} scroll={false}>
|
||||
<InfiniteFetch
|
||||
query={VideosModal.query(slug)}
|
||||
layout={{ layout: "vertical", gap: 8, numColumns: 1, size: 48 }}
|
||||
@ -17,9 +21,10 @@ export const VideosModal = () => {
|
||||
<View className="h-12 flex-row items-center justify-between hover:bg-card">
|
||||
<P>{item.path}</P>
|
||||
<ComboBox
|
||||
label={"toto"}
|
||||
value={null}
|
||||
// value={item.entries.map((x) => 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) => {}}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@ -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" && <HR />} */}
|
||||
{/* <Menu.Item */}
|
||||
{/* label={t("home.refreshMetadata")} */}
|
||||
{/* icon={Refresh} */}
|
||||
{/* onSelect={() => metadataRefreshMutation.mutate()} */}
|
||||
{/* /> */}
|
||||
{/* </> */}
|
||||
{/* )} */}
|
||||
{account?.isAdmin === true && (
|
||||
<>
|
||||
{kind === "movie" && <HR />}
|
||||
<Menu.Item
|
||||
label={t("show.videos-map")}
|
||||
icon={VideoLibrary}
|
||||
href={`/series/${slug}/videos`}
|
||||
/>
|
||||
{/* <Menu.Item */}
|
||||
{/* label={t("home.refreshMetadata")} */}
|
||||
{/* icon={Refresh} */}
|
||||
{/* onSelect={() => metadataRefreshMutation.mutate()} */}
|
||||
{/* /> */}
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
)}
|
||||
</View>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user