mirror of
https://github.com/Zagrios/bs-manager.git
synced 2026-07-03 14:08:25 +02:00
[feature-107] advancements on playlist filter panel
This commit is contained in:
@@ -21,7 +21,9 @@ export class BeatSaverApiService {
|
||||
}
|
||||
|
||||
private objectToStringRecord(obj: Record<string, any>): Record<string, string> {
|
||||
return Object.fromEntries(Object.entries(obj).map(([key, value]) => [key, String(value)] as [string, string]));
|
||||
return Object.fromEntries(Object.entries(obj)
|
||||
.filter(([_, value]) => value !== undefined && value !== null)
|
||||
.map(([key, value]) => [key, String(value)] as [string, string]));
|
||||
}
|
||||
|
||||
private mapFilterToUrlParams(filter: MapFilter): URLSearchParams {
|
||||
|
||||
@@ -68,12 +68,7 @@ export function FilterPanel({ className, ref, playlist = false, filter, localDat
|
||||
|
||||
const renderNpsLabel = (nps: number): JSX.Element => {
|
||||
const textValue = (() => {
|
||||
if (nps === MIN_NPS) {
|
||||
return MIN_NPS;
|
||||
}
|
||||
if (nps === MAX_NPS) {
|
||||
return "∞";
|
||||
}
|
||||
if (nps === MAX_NPS) { return "∞"; }
|
||||
return nps;
|
||||
})();
|
||||
|
||||
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
import { useState } from "react";
|
||||
import { BsmCheckbox } from "renderer/components/shared/bsm-checkbox.component";
|
||||
import { BsmRange } from "renderer/components/shared/bsm-range.component";
|
||||
import { cn } from "renderer/helpers/css-class.helpers";
|
||||
import { useTranslation } from "renderer/hooks/use-translation.hook";
|
||||
import { PlaylistSearchParams } from "shared/models/maps/beat-saver.model"
|
||||
import {DateRangePicker} from "@nextui-org/date-picker";
|
||||
import { DateValue, Radio, RadioGroup, RadioProps, RangeValue } from "@nextui-org/react";
|
||||
import { today, getLocalTimeZone, parseAbsoluteToLocal, now } from "@internationalized/date";
|
||||
import { motion } from "framer-motion";
|
||||
import { useOnUpdate } from "renderer/hooks/use-on-update.hook";
|
||||
import { BsmButton } from "renderer/components/shared/bsm-button.component";
|
||||
import { GlowEffect } from "renderer/components/shared/glow-effect.component";
|
||||
import { useConstant } from "renderer/hooks/use-constant.hook";
|
||||
import equal from "fast-deep-equal";
|
||||
|
||||
export type FilterPlaylistSearchParams = Pick<PlaylistSearchParams, "curated"|"verified"|"includeEmpty"|"from"|"to"|"minNps"|"maxNps">;
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
params?: FilterPlaylistSearchParams;
|
||||
onChange?: (params: FilterPlaylistSearchParams) => void;
|
||||
onSubmit?: (params: FilterPlaylistSearchParams) => void;
|
||||
}
|
||||
|
||||
const MAX_NPS = 17;
|
||||
const MIN_NPS = 0;
|
||||
|
||||
export function DownloadPlaylistFilterPanel({ className, params, onChange, onSubmit }: Props) {
|
||||
|
||||
const t = useTranslation();
|
||||
|
||||
const [curated, setCurated] = useState<boolean>(params?.curated);
|
||||
const [verified, setVerified] = useState<boolean>(params?.verified);
|
||||
const [includeEmpty, setIncludeEmpty] = useState<boolean>(params?.includeEmpty);
|
||||
const [minNps, setMinNps] = useState<number>(params?.minNps);
|
||||
const [maxNps, setMaxNps] = useState<number>(params?.maxNps);
|
||||
const [dateRange, setDateRange] = useState<RangeValue<DateValue>>({
|
||||
start: null,
|
||||
end: null,
|
||||
});
|
||||
|
||||
const filterValue: FilterPlaylistSearchParams = { curated, verified, includeEmpty, minNps, maxNps, from: dateRange.start?.toString(), to: dateRange.end?.toString() };
|
||||
const firstFilterValue = useConstant(() => ({...filterValue}));
|
||||
const filterHasBeenChanged = !equal(firstFilterValue, filterValue);
|
||||
|
||||
useOnUpdate(() => {
|
||||
onChange?.(filterValue);
|
||||
}, [curated, verified, includeEmpty, minNps, maxNps, dateRange.end, dateRange.start]);
|
||||
|
||||
const handleOnNpsChange = ([min, max]: number[]) => {
|
||||
setMinNps(() => min <= MIN_NPS ? undefined : min);
|
||||
setMaxNps(() => max >= MAX_NPS ? undefined : max);
|
||||
}
|
||||
|
||||
const renderNpsLabel = (value: number) => {
|
||||
const label = value === MAX_NPS ? `∞` : value;
|
||||
return renderRangeLabel(label, value === MAX_NPS);
|
||||
}
|
||||
|
||||
const renderRangeLabel = (text: string | number, isMax: boolean): JSX.Element => {
|
||||
return <span className={`bg-inherit absolute top-[calc(100%+4px)] whitespace-nowrap h-5 font-bold rounded-md shadow-center shadow-black px-1 flex items-center ${isMax ? "text-lg" : "text-sm"}`}>{text}</span>;
|
||||
};
|
||||
|
||||
const CustomRadio = (props: RadioProps) => {
|
||||
const {children, ...otherProps} = props;
|
||||
|
||||
return (
|
||||
<Radio
|
||||
{...otherProps}
|
||||
classNames={{
|
||||
base: cn(
|
||||
"flex-none m-0 h-8 bg-content1 hover:bg-content2 items-center justify-between",
|
||||
"cursor-pointer rounded-full border-2 border-default-200/60",
|
||||
"data-[selected=true]:border-primary",
|
||||
),
|
||||
label: "text-tiny text-default-500",
|
||||
labelWrapper: "px-1 m-0",
|
||||
wrapper: "hidden",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Radio>
|
||||
);
|
||||
};
|
||||
|
||||
const handleDatePreset = (radioElt: HTMLInputElement) => {
|
||||
const rawSplited = radioElt.value.split("_");
|
||||
const [type, value] = [rawSplited[0], parseInt(rawSplited.at(1))];
|
||||
|
||||
if(!value || !type){
|
||||
return setDateRange(() => ({ start: null, end: null }));
|
||||
};
|
||||
|
||||
const end = today(getLocalTimeZone());
|
||||
|
||||
setDateRange(() => ({
|
||||
start: end.subtract({ [type]: value }),
|
||||
end: end.add({days: 1})
|
||||
}))
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div className={cn("theme-color-3 flex flex-row gap-3 p-2 absolute origin-top shadow-md shadow-black rounded-md", className)} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{duration: .1}}>
|
||||
<div className="flex flex-col gap-1 shrink-0">
|
||||
<h2 className="mb-0.5 uppercase text-sm">{t("maps.map-filter-panel.specificities")}</h2>
|
||||
<div className="flex flex-row h-6 gap-1">
|
||||
<BsmCheckbox className="relative h-full aspect-square" checked={curated} onChange={setCurated}/>
|
||||
<span>Curated</span>
|
||||
</div>
|
||||
<div className="flex flex-row h-6 gap-1">
|
||||
<BsmCheckbox className="relative h-full aspect-square" checked={verified} onChange={setVerified}/>
|
||||
<span>Verified mapper</span>
|
||||
</div>
|
||||
<div className="flex flex-row h-6 gap-1">
|
||||
<BsmCheckbox className="relative h-full aspect-square" checked={includeEmpty} onChange={setIncludeEmpty}/>
|
||||
<span>Include empty playlists</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end grow shrink-0 min-w-64 gap-2">
|
||||
<div className="w-full flex flex-col justify-center p-2">
|
||||
<BsmRange values={[minNps ?? MIN_NPS, maxNps ?? MAX_NPS]} min={MIN_NPS} max={MAX_NPS} step={0.1} onChange={handleOnNpsChange} renderLabel={renderNpsLabel}/>
|
||||
<span className=" text-sm font-bold text-center mt-2.5">{t("maps.map-filter-panel.nps")}</span>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<DateRangePicker label="Start date — End date" value={dateRange} onChange={setDateRange} classNames={{
|
||||
inputWrapper: "pointer-events-none bg-light-main-color-1 hover:bg-light-main-color-1 dark:bg-main-color-1 dark:hover:bg-main-color-1 hover:brightness-75",
|
||||
input: "pointer-events-auto",
|
||||
selectorButton: "pointer-events-auto",
|
||||
}} CalendarBottomContent={
|
||||
<RadioGroup onChange={e => handleDatePreset(e.target)} classNames={{
|
||||
base: "w-[var(--calendar-width)] pb-2",
|
||||
wrapper: "flex flex-row flex-wrap -my-2.5 py-2.5 px-3 gap-1 overflow-scroll max-w-[var(--calendar-width)] w-[var(--calendar-width)]"
|
||||
}}>
|
||||
<CustomRadio value="days_0">All</CustomRadio>
|
||||
<CustomRadio value="days_1">Last 24h</CustomRadio>
|
||||
<CustomRadio value="weeks_1">Last week</CustomRadio>
|
||||
<CustomRadio value="months_1">Last month</CustomRadio>
|
||||
<CustomRadio value="months_3">Last 3 months</CustomRadio>
|
||||
</RadioGroup>
|
||||
}/>
|
||||
</div>
|
||||
{ onSubmit && (
|
||||
<div className="float-right relative w-fit h-7 my-0.5">
|
||||
<BsmButton className="flex justify-center items-center rounded-md font-bold px-3 pb-0.5 text-sm size-full" text="misc.apply" typeColor="primary" withBar={false} onClick={() => onSubmit(filterValue)} />
|
||||
<GlowEffect visible={filterHasBeenChanged} />
|
||||
</div>
|
||||
) }
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
+19
-11
@@ -1,11 +1,11 @@
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { BsmButton } from "renderer/components/shared/bsm-button.component";
|
||||
import { BsmDropdownButton } from "renderer/components/shared/bsm-dropdown-button.component";
|
||||
import { BsmSelect, BsmSelectOption } from "renderer/components/shared/bsm-select.component";
|
||||
import { cn } from "renderer/helpers/css-class.helpers";
|
||||
import { useConstant } from "renderer/hooks/use-constant.hook";
|
||||
import { useOnUpdate } from "renderer/hooks/use-on-update.hook";
|
||||
import { PlaylistSearchParams, BsvSearchOrder } from "shared/models/maps/beat-saver.model";
|
||||
import { DownloadPlaylistFilterPanel, FilterPlaylistSearchParams } from "./download-playlist-filter-panel.component";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
@@ -17,8 +17,13 @@ type Props = {
|
||||
|
||||
export function DownloadPlaylistModalHeader({ className, value, onSubmit }: Props) {
|
||||
|
||||
const dropDownFilterRef = useRef(null);
|
||||
|
||||
const [query, setQuery] = useState<string>(value?.q || "");
|
||||
const [order, setOrder] = useState<BsvSearchOrder>(value?.sortOrder || BsvSearchOrder.Latest);
|
||||
const [filter, setFilter] = useState<FilterPlaylistSearchParams>({});
|
||||
|
||||
const searchParams: Omit<PlaylistSearchParams, "page"> = { q: query, sortOrder: order };
|
||||
|
||||
const sortOptions: BsmSelectOption<BsvSearchOrder>[] = useConstant(() => {
|
||||
return Object.values(BsvSearchOrder).reduce((acc, value) => {
|
||||
@@ -28,23 +33,26 @@ export function DownloadPlaylistModalHeader({ className, value, onSubmit }: Prop
|
||||
}, []);
|
||||
});
|
||||
|
||||
useOnUpdate(() => {
|
||||
submit();
|
||||
}, [order]);
|
||||
|
||||
const handleOrderChange = (value: BsvSearchOrder) => {
|
||||
setOrder(() => value);
|
||||
submit({...searchParams, sortOrder: value})
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
const handleFilterSubmit = (value: FilterPlaylistSearchParams) => {
|
||||
setFilter(() => value);
|
||||
submit({...searchParams, ...value});
|
||||
dropDownFilterRef.current?.close?.();
|
||||
};
|
||||
|
||||
const submit = (value: Omit<PlaylistSearchParams, "page">) => {
|
||||
if(!onSubmit){ return; }
|
||||
onSubmit({ q: query, sortOrder: order });
|
||||
onSubmit(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<form className={cn('flex flex-row gap-2', className)} onSubmit={e => {e.preventDefault(); submit();}}>
|
||||
<BsmDropdownButton buttonClassName="flex items-center justify-center h-full rounded-full px-2 py-1 !bg-light-main-color-1 dark:!bg-main-color-1" icon="filter" text="pages.version-viewer.maps.search-bar.filters-btn" withBar={false}>
|
||||
<span>Filter Panel</span>
|
||||
<form className={cn('flex flex-row gap-2', className)} onSubmit={e => {e.preventDefault(); submit(searchParams);}}>
|
||||
<BsmDropdownButton ref={dropDownFilterRef} buttonClassName="flex items-center justify-center h-full rounded-full px-2 py-1 !bg-light-main-color-1 dark:!bg-main-color-1" icon="filter" text="pages.version-viewer.maps.search-bar.filters-btn" withBar={false}>
|
||||
<DownloadPlaylistFilterPanel className="z-10 translate-y-1" params={filter} onSubmit={handleFilterSubmit}/>
|
||||
</BsmDropdownButton>
|
||||
<input className="h-full theme-color-1 rounded-full px-2 grow pb-0.5" type="text" placeholder="Rechercher une playlist" value={query} onChange={e => setQuery(e.target.value)} />
|
||||
<BsmButton type="submit" className="shrink-0 rounded-full py-1 px-3 !theme-color-1 flex justify-center items-center capitalize" icon="search" text="modals.download-maps.search-btn" withBar={false} />
|
||||
|
||||
+3
-2
@@ -38,7 +38,8 @@ export const DownloadPlaylistModal: ModalComponent<void, {version: BSVersion, ow
|
||||
useOnUpdate(() => {
|
||||
setLoading(() => true);
|
||||
beatSaver.searchPlaylists(searchParams)
|
||||
.then(playlists => setPlaylists(prev => [...prev, ...playlists]))
|
||||
.then(playlists => setPlaylists(prev => [...prev, ...(playlists ?? [])]))
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(() => false));
|
||||
}, [searchParams])
|
||||
|
||||
@@ -62,7 +63,7 @@ export const DownloadPlaylistModal: ModalComponent<void, {version: BSVersion, ow
|
||||
return <div className="flex justify-center items-center h-full w-full">No playlists found</div>
|
||||
}
|
||||
return (
|
||||
<ul className="p-2 size-full flex flex-row flex-wrap justify-start content-start gap-3 grow overflow-y-scroll overflow-x-hidden z-10">
|
||||
<ul className="p-2 size-full flex flex-row flex-wrap justify-start content-start gap-3 grow overflow-y-scroll overflow-x-hidden z-[1]">
|
||||
{playlists.map(playlist => (
|
||||
<PlaylistItem
|
||||
key={playlist.playlistId}
|
||||
|
||||
Reference in New Issue
Block a user