[feature-107] progress on the modal for creating and editing playlists

This commit is contained in:
MathieuG-P
2024-06-16 21:13:54 +02:00
parent b1ee9f054d
commit 5e1a021c3a
7 changed files with 297 additions and 101 deletions
@@ -226,18 +226,14 @@ export function FilterPanel({ className, ref, playlist = false, filter, localDat
// Filter functions
function isFitEnabledTags(filter: MapFilter, tags: MapTag[]): boolean {
if (!filter?.enabledTags || filter.enabledTags.size === 0) { return true; }
if(!Array.isArray(tags)) { return false; }
if (!filter?.enabledTags || filter.enabledTags.size === 0) {
return true;
}
return Array.from(filter.enabledTags.values()).every(tag => tags.some(mapTag => mapTag === tag));
}
function isFitExcludedTags(filter: MapFilter, tags: MapTag[]): boolean {
if (!filter?.excludedTags || filter.excludedTags.size === 0) { return true; }
if(!Array.isArray(tags)) { return false; }
if (!filter?.excludedTags || filter.excludedTags.size === 0) {
return true;
}
return !tags.some(tag => filter.excludedTags.has(tag as MapTag));
}
@@ -325,7 +321,7 @@ export const isLocalMapFitMapFilter = ({filter, map, search}: { filter: MapFilte
if (!isFitChroma(filter, map.songDetails?.difficulties.some(diff => !!diff.chroma))) { return false; }
if (!isFitFullSpread(filter, map.songDetails?.difficulties.length)) { return false; }
if (!isFitAutomapper(filter, map.songDetails?.automapper)){ return false; }
if (!isFitRanked(filter, map.songDetails?.ranked)) { return false; }
if (!isFitRanked(filter, map.songDetails?.ranked || map.songDetails?.rankedBL)) { return false; }
if (!isFitCurated(filter, map.songDetails?.curated)) { return false; }
if (!isFitVerified(filter, map.songDetails?.uploader.verified)) { return false; }
if (!isFitSearch(search, {songName: map.rawInfo?._songName, songAuthorName: map.rawInfo?._songAuthorName, levelAuthorName: map.rawInfo?._levelAuthorName})) { return false; }
@@ -333,22 +329,25 @@ export const isLocalMapFitMapFilter = ({filter, map, search}: { filter: MapFilte
};
export const isBsvMapFitMapFilter = ({filter, map, search}: { filter: MapFilter, map: BsvMapDetail, search: string }): boolean => {
if (!isFitEnabledTags(filter, map.tags)) { return false; }
console.log("LAAAAAA", map);
if (!isFitEnabledTags(filter, map.tags)) { console.log(1); return false; }
if (!isFitExcludedTags(filter, map.tags)) { return false; }
if (map.versions?.at(0)?.diffs && !map.versions.at(0).diffs.some(diff => isFitMinNps(filter, diff.nps))) { return false; }
if (map.versions?.at(0)?.diffs && !map.versions.at(0).diffs.some(diff => isFitMaxNps(filter, diff.nps))) { return false; }
if (!isFitMinDuration(filter, map.metadata.duration)) { return false; }
if (!isFitMaxDuration(filter, map.metadata.duration)) { return false; }
if (!isFitNoodle(filter, map.versions?.at(0)?.diffs.some(diff => !!diff.ne))) { return false; }
if (!isFitMe(filter, map.versions?.at(0)?.diffs.some(diff => !!diff.me))) { return false; }
if (!isFitCinema(filter, map.versions?.at(0)?.diffs.some(diff => !!diff.cinema))) { return false; }
if (!isFitChroma(filter, map.versions?.at(0)?.diffs.some(diff => !!diff.chroma))) { return false; }
if (!isFitFullSpread(filter, map.versions?.at(0)?.diffs.length)) { return false; }
if (!isFitAutomapper(filter, map.automapper)){ return false; }
if (!isFitRanked(filter, map.ranked)) { return false; }
if (!isFitCurated(filter, !!map.curator)) { return false; }
if (!isFitVerified(filter, !!map.curatedAt)) { return false; }
if (!isFitSearch(search, {songName: map.name, songAuthorName: map.metadata.songAuthorName, levelAuthorName: map.metadata.levelAuthorName})) { return false; }
if (map.versions?.at(0)?.diffs && !map.versions.at(0).diffs.some(diff => isFitMinNps(filter, diff.nps))) { console.log(2); return false; }
if (map.versions?.at(0)?.diffs && !map.versions.at(0).diffs.some(diff => isFitMaxNps(filter, diff.nps))) { console.log(3); return false; }
if (!isFitMinDuration(filter, map.metadata.duration)) { console.log(4); return false; }
if (!isFitMaxDuration(filter, map.metadata.duration)) { console.log(5); return false; }
if (!isFitNoodle(filter, map.versions?.at(0)?.diffs.some(diff => !!diff.ne))) { console.log(6); return false; }
if (!isFitMe(filter, map.versions?.at(0)?.diffs.some(diff => !!diff.me))) { console.log(7); return false; }
if (!isFitCinema(filter, map.versions?.at(0)?.diffs.some(diff => !!diff.cinema))) { console.log(8); return false; }
if (!isFitChroma(filter, map.versions?.at(0)?.diffs.some(diff => !!diff.chroma))) { console.log(9); return false; }
if (!isFitFullSpread(filter, map.versions?.at(0)?.diffs.length)) { console.log(10); return false; }
if (!isFitAutomapper(filter, map.automapper)){ console.log(11); return false; }
if (!isFitRanked(filter, map.ranked || map.blRanked)) { console.log(12); return false; }
if (!isFitCurated(filter, !!map.curator)) { console.log(13); return false; }
if (!isFitVerified(filter, !!map.curatedAt)) { console.log(14); return false; }
if (!isFitSearch(search, {songName: map.name, songAuthorName: map.metadata.songAuthorName, levelAuthorName: map.metadata.levelAuthorName})) { console.log(15); return false; }
return true;
};
@@ -365,7 +364,7 @@ export const isSongDetailsFitMapFilter = ({filter, map, search}: { filter: MapFi
if (!isFitChroma(filter, map.difficulties.some(diff => !!diff.chroma))) { return false; }
if (!isFitFullSpread(filter, map.difficulties.length)) { return false; }
if (!isFitAutomapper(filter, map.automapper)){ return false; }
if (!isFitRanked(filter, map.ranked)) { return false; }
if (!isFitRanked(filter, map.ranked || map.rankedBL)) { return false; }
if (!isFitCurated(filter, map.curated)) { return false; }
if (!isFitVerified(filter, map.uploader.verified)) { return false; }
if (!isFitSearch(search, {songName: map.name, songAuthorName: map.uploader.name, levelAuthorName: map.uploader.name})) { return false; }
@@ -24,6 +24,7 @@ import { BsvMapDetail, RawMapInfoData, SongDetailDiffCharactertistic, SongDetail
import { useConstant } from "renderer/hooks/use-constant.hook";
import { CalendarDateTime, getLocalTimeZone } from "@internationalized/date";
import { typedMemo } from "renderer/helpers/typed-memo";
import { Observable, of } from "rxjs";
export type ParsedMapDiff = { type: SongDiffName; name: string; stars: number };
@@ -37,14 +38,17 @@ export type MapItemComponentProps<T = unknown> = {
autorId: number;
mapId: string;
diffs: Map<SongDetailDiffCharactertistic, ParsedMapDiff[]>;
ranked: boolean;
ranked?: boolean;
blRanked?: boolean;
bpm?: number;
duration: number;
likes: number;
createdAt: number | CalendarDateTime;
selected?: boolean;
selected$?: Observable<boolean>;
downloading?: boolean;
showOwned?: boolean;
isOwned$?: Observable<boolean>;
callBackParam: T;
onDelete?: (param: T) => void;
onDownload?: (param: T) => void;
@@ -53,7 +57,7 @@ export type MapItemComponentProps<T = unknown> = {
onDoubleClick?: (param: T) => void;
};
export function MapItemComponent <T = unknown>({ hash, title, autor, songAutor, coverUrl, songUrl, autorId, mapId, diffs, ranked, bpm, duration, likes, createdAt, selected, downloading, showOwned, callBackParam, onDelete, onDownload, onSelected, onCancelDownload, onDoubleClick }: MapItemComponentProps<T>) {
export function MapItemComponent <T = unknown>({ hash, title, autor, songAutor, coverUrl, songUrl, autorId, mapId, diffs, ranked, blRanked, bpm, duration, likes, createdAt, selected, selected$, downloading, showOwned, isOwned$, callBackParam, onDelete, onDownload, onSelected, onCancelDownload, onDoubleClick }: MapItemComponentProps<T>) {
const linkOpener = useService(LinkOpenerService);
const audioPlayer = useService(AudioPlayerService);
@@ -61,6 +65,8 @@ export function MapItemComponent <T = unknown>({ hash, title, autor, songAutor,
const t = useTranslation();
const ref = useRef(null);
const isSelected = useObservable(() => selected$ ?? of(selected), false, [selected$, selected]);
const isOwned = useObservable(() => isOwned$ ?? of(showOwned ?? false), false, [isOwned$, showOwned]);
const [hovered, setHovered] = useState(false);
const [bottomBarHovered, setBottomBarHovered, cancelBottomBarHovered] = useDelayedState(false);
const [diffsPanelHovered, setDiffsPanelHovered] = useState(false);
@@ -162,7 +168,7 @@ export function MapItemComponent <T = unknown>({ hash, title, autor, songAutor,
return (
<motion.li className="relative h-[100px] min-w-[370px] shrink-0 grow basis-0 text-white group cursor-pointer" onHoverStart={() => setHovered(true)} onHoverEnd={() => setHovered(false)} style={{ zIndex: hovered && 5, transform: "translateZ(0) scale(1.0, 1.0)", backfaceVisibility: "hidden" }}>
<GlowEffect visible={hovered || (selected && !!onSelected)} />
<GlowEffect visible={hovered || (isSelected && !!onSelected)} />
<AnimatePresence>
{(diffsPanelHovered || bottomBarHovered) && (
<motion.ul key={hash} className="absolute top-[calc(100%-10px)] w-full h-fit max-h-[200%] pt-4 pb-2 px-2 overflow-y-scroll bg-light-main-color-3 dark:bg-main-color-3 text-main-color-1 dark:text-current brightness-125 rounded-md flex flex-col gap-3 scrollbar-default shadow-sm shadow-black" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15 }} onHoverStart={diffsPanelHoverStart} onHoverEnd={diffsPanelHoverEnd}>
@@ -183,7 +189,7 @@ export function MapItemComponent <T = unknown>({ hash, title, autor, songAutor,
)}
</AnimatePresence>
<div className="h-full w-full relative pl-[100px] rounded-md overflow-hidden flex flex-row justify-end">
<div className={`absolute top-0 left-0 h-full aspect-square cursor-pointer ${showOwned && "border-l-[5px]"}`} style={{ borderColor: showOwned && color }}>
<div className={`absolute top-0 left-0 h-full aspect-square cursor-pointer ${isOwned && "border-l-[5px]"}`} style={{ borderColor: isOwned && color }}>
<BsmImage className="size-full object-cover" image={coverUrl} placeholder={defaultImage} errorImage={defaultImage} />
<span
className="absolute flex justify-center items-center size-full pr-1 bg-transparent top-0 left-0 group-hover:bg-black group-hover:bg-opacity-40"
@@ -243,6 +249,11 @@ export function MapItemComponent <T = unknown>({ hash, title, autor, songAutor,
<span className="uppercase text-xs font-bold tracking-wide brightness-[.25]">{t("maps.map-specificities.ranked")}</span>
</div>
)}
{blRanked && (
<div className="bg-pink-400 bg-current rounded-full px-1 h-full flex items-center justify-center">
<span className="uppercase text-xs font-bold tracking-wide brightness-[.25]">{t("maps.map-specificities.ranked")}</span>
</div>
)}
<div className="h-full grow flex items-start content-start">{renderDiffPreview()}</div>
</motion.div>
</div>
@@ -1,15 +1,15 @@
import { useCallback, useMemo, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { FilterPanel, isLocalMapFitMapFilter, isMapFitFilter } from "renderer/components/maps-playlists-panel/maps/filter-panel.component";
import { MapItem, extractMapDiffs } from "renderer/components/maps-playlists-panel/maps/map-item.component";
import { BsmDropdownButton } from "renderer/components/shared/bsm-dropdown-button.component";
import { BsmSelect } from "renderer/components/shared/bsm-select.component";
import { VirtualScroll } from "renderer/components/shared/virtual-scroll/virtual-scroll.component";
import { BsmSelect, BsmSelectOption } from "renderer/components/shared/bsm-select.component";
import { VirtualScroll, VirtualScrollEndHandler } from "renderer/components/shared/virtual-scroll/virtual-scroll.component";
import { ChevronTopIcon } from "renderer/components/svgs/icons/chevron-top-icon.component";
import { useObservable } from "renderer/hooks/use-observable.hook";
import { useOnUpdate } from "renderer/hooks/use-on-update.hook";
import { useThemeColor } from "renderer/hooks/use-theme-color.hook";
import { ModalComponent } from "renderer/services/modale.service"
import { Observable, filter, lastValueFrom, take } from "rxjs";
import { BehaviorSubject, Observable, Subject, filter, lastValueFrom, map, of, take } from "rxjs";
import { BSVersion } from "shared/bs-version.interface";
import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface";
import { LocalBPList, LocalBPListsDetails } from "shared/models/playlists/local-playlist.models"
@@ -19,7 +19,11 @@ import { useService } from "renderer/hooks/use-service.hook";
import { MapsManagerService } from "renderer/services/maps-manager.service";
import { useTranslation } from "renderer/hooks/use-translation.hook";
import { MapItemComponentPropsMapper } from "shared/mappers/map/map-item-component-props.mapper";
import { MapFilter } from "shared/models/maps/beat-saver.model";
import { BsvSearchOrder, MapFilter, SearchParams } from "shared/models/maps/beat-saver.model";
import { useConstant } from "renderer/hooks/use-constant.hook";
import Tippy from "@tippyjs/react";
import { BeatSaverService } from "renderer/services/thrird-partys/beat-saver.service";
import { BsmButton } from "renderer/components/shared/bsm-button.component";
type Props = {
version?: BSVersion;
@@ -33,41 +37,74 @@ export const EditPlaylistModal: ModalComponent<LocalBPListsDetails, Props> = ({
const color = useThemeColor("first-color");
const mapsService = useService(MapsManagerService);
const beatSaver = useService(BeatSaverService);
const maps = useObservable(() => maps$.pipe(filter(Array.isArray), take(1)), undefined);
const keyPressed$ = useConstant(() => new BehaviorSubject<string|undefined>(undefined));
const filterContainerRef = useRef(null);
const localMaps$ = useConstant(() => new BehaviorSubject<(BsmLocalMap|SongDetails)[]>(undefined));
const localMaps = useObservable(() => localMaps$, undefined);
const availabledHashsSelected$ = useConstant(() => new BehaviorSubject<string[]>([]));
const [availableMapsSearch, setAvailableMapsSearch] = useState<string>("");
const [availableMapsFilter, setAvailableMapsFilter] = useState<MapFilter>({});
const [bsvLoading, setBsvLoading] = useState<boolean>(false);
const bsvMaps$ = useConstant(() => new BehaviorSubject<BsvMapDetail[]>(undefined));
const bsvMaps = useObservable(() => bsvMaps$, undefined);
const [bsvSearchOrder, setBsvSortOrder] = useState<BsvSearchOrder>(BsvSearchOrder.Latest);
const [bsvSearchParams, setBsvSearchParams] = useState<SearchParams>({
sortOrder: bsvSearchOrder,
filter: availableMapsFilter,
page: 0,
q: availableMapsSearch,
});
const sortOptions: BsmSelectOption<BsvSearchOrder>[] = useConstant(() => {
return Object.values(BsvSearchOrder).map(sort => ({ text: `beat-saver.maps-sorts.${sort}`, value: sort }));
});
const playlistMaps$ = useConstant(() => new BehaviorSubject<Record<string, (BsmLocalMap|SongDetails|BsvMapDetail)>>(undefined));
const playlistMaps = useObservable(() => playlistMaps$, undefined);
const playlistHashsSelected$ = useConstant(() => new BehaviorSubject<string[]>([]));
const [playlistMapsSearch, setPlaylistMapsSearch] = useState<string>("");
const [playlistMapsFilter, setPlaylistMapsFilter] = useState<MapFilter>({});
const [availableMapsSource, setAvailableMapsSource] = useState<number>(0);
const [playlistMaps, setPlaylistMaps] = useState<Record<string, (BsmLocalMap|BsvMapDetail|SongDetails)>>();
const displayablePlaylistMaps = useMemo(() => playlistMaps ? Object.values(playlistMaps).filter(Boolean) : [], [playlistMaps]);
const availableMaps = (() => {
useOnUpdate(() => {
const keyDown = (e: KeyboardEvent) => keyPressed$.next(e.key);
document.addEventListener("keydown", keyDown);
})();
const keyUp = () => keyPressed$.next(undefined);
document.addEventListener("keyup", keyUp);
return () => {
document.removeEventListener("keydown", keyDown);
document.removeEventListener("keyup", keyUp);
}
}, []);
useOnUpdate(() => {
if(!maps){ return; }
const playlistMapsRes = (playlist?.songs ?? []).reduce((acc, song) => {
const map = maps.find(map => map.hash === song.hash);
if(map){
acc[map.hash] = map;
}
else {
acc[song.hash] = undefined;
}
return acc;
}, {} as Record<string, (BsmLocalMap|BsvMapDetail|SongDetails)>);
(async () => {
const maps = await lastValueFrom(maps$.pipe(take(1)));
const playlistMapsRes = (playlist?.songs ?? []).reduce((acc, song) => {
const map = maps.find(map => map.hash === song.hash);
if(map){
acc[map.hash] = map;
}
else {
acc[song.hash] = undefined;
}
return acc;
}, {} as Record<string, (BsmLocalMap|BsvMapDetail|SongDetails)>);
const notInstalledHashs = Object.keys(playlistMapsRes).filter(hash => !playlistMapsRes[hash]);
const songsDetails = await lastValueFrom(mapsService.getMapsInfoFromHashs(notInstalledHashs));
@@ -75,64 +112,179 @@ export const EditPlaylistModal: ModalComponent<LocalBPListsDetails, Props> = ({
playlistMapsRes[song.hash] = song;
});
localMaps$.next([...(maps ?? []), ...(songsDetails ?? [])]);
playlistMaps$.next(playlistMapsRes ?? {});
})()
.catch(logRenderError)
.finally(() => setPlaylistMaps(playlistMapsRes));
}, [maps]);
.catch(logRenderError);
}, []);
useOnUpdate(() => {
availabledHashsSelected$.next([]);
setAvailableMapsFilter({});
setAvailableMapsSearch("");
bsvMaps$.next([]);
setBsvSearchParams({ sortOrder: bsvSearchOrder, filter: availableMapsFilter, page: 0, q: availableMapsSearch });
}, [availableMapsSource])
}, [maps, playlistMaps, playlist]);
useOnUpdate(() => {
if(availableMapsSource === 0){ return; }
const renderMapItem = useCallback((map: (BsmLocalMap|BsvMapDetail|SongDetails), onClick: (map: (BsmLocalMap|BsvMapDetail|SongDetails)) => void) => {
setBsvLoading(true);
(async () => {
const maps = await beatSaver.searchMaps(bsvSearchParams);
bsvMaps$.next([...(bsvMaps ?? []), ...maps]);
setBsvLoading(false);
})()
.catch(logRenderError)
}, [bsvSearchParams])
const getHashOfMap = useCallback((map: (BsmLocalMap|BsvMapDetail|SongDetails)) => {
return (map as BsmLocalMap | SongDetails).hash ?? (map as BsvMapDetail).versions?.[0]?.hash;
}, []);
const handleOnClickMap = useCallback(async ({ map, selectedHashs$, mapsSource$, noKeyPressedFallBack } : {
map: BsmLocalMap | BsvMapDetail | SongDetails;
mapsSource$: Observable<(BsmLocalMap|BsvMapDetail|SongDetails)[]>;
selectedHashs$: BehaviorSubject<string[]>;
noKeyPressedFallBack?: () => void;
}) => {
const keyPressed = keyPressed$.value;
const mapHash = getHashOfMap(map);
if(keyPressed === "Control"){
const selectedHashs = selectedHashs$.value;
const newSelectedHashs = selectedHashs.includes(mapHash) ? selectedHashs.filter(hash => hash !== mapHash) : [...selectedHashs, mapHash];
return selectedHashs$.next(newSelectedHashs);
}
const mapsSource = await lastValueFrom(mapsSource$.pipe(take(1)));
if(keyPressed === "Shift" && Array.isArray(mapsSource)){
const selectedHashs = selectedHashs$.value;
let lastSelectedMapIndex = mapsSource.findIndex(map => getHashOfMap(map) === selectedHashs.at(0));
lastSelectedMapIndex = lastSelectedMapIndex === -1 ? 0 : lastSelectedMapIndex;
const currentMapIndex = mapsSource.findIndex(map => getHashOfMap(map) === mapHash);
const newSelectedHashs = mapsSource.slice(Math.min(lastSelectedMapIndex, currentMapIndex), Math.max(lastSelectedMapIndex, currentMapIndex) + 1).map(map => getHashOfMap(map));
return selectedHashs$.next(newSelectedHashs);
}
noKeyPressedFallBack?.();
selectedHashs$.next([]);
}, []);
const renderMapItem = useCallback((map: (BsmLocalMap|BsvMapDetail|SongDetails), onClick: (map: (BsmLocalMap|BsvMapDetail|SongDetails)) => void, isSelected$: Observable<boolean>, isOwned$?: Observable<boolean>) => {
return (
<MapItem
key={(map as BsmLocalMap | SongDetails).hash ?? (map as BsvMapDetail).versions?.[0]?.hash}
{ ...MapItemComponentPropsMapper.from(map) }
selected$={isSelected$}
onSelected={onClick}
isOwned$={isOwned$}
/>
);
}, []);
const renderAvailableMapItem = useCallback((map: BsmLocalMap) => {
return renderMapItem(map, () => setPlaylistMaps(prev => ({ ...prev, [map.hash]: map })));
const renderAvailableMapItem = useCallback((localMap: BsmLocalMap | SongDetails | BsvMapDetail) => {
const mapHash = getHashOfMap(localMap);
const isSelected$ = availabledHashsSelected$.pipe(map(hashs => hashs.includes(mapHash)));
return renderMapItem(localMap, () => handleOnClickMap({
map: localMap,
mapsSource$: localMaps$ as BehaviorSubject<(BsmLocalMap|BsvMapDetail|SongDetails)[]>,
selectedHashs$: availabledHashsSelected$,
noKeyPressedFallBack: () => playlistMaps$.next(Object.assign({[mapHash]: localMap}, playlistMaps$.value))
}), isSelected$);
}, []);
const renderPlaylistMapItem = useCallback((map: (BsmLocalMap|BsvMapDetail|SongDetails)) => {
return renderMapItem(map, () => setPlaylistMaps(prev => {
if(!map){ return prev; }
const hash = (map as BsmLocalMap | SongDetails).hash ? (map as BsmLocalMap|SongDetails).hash : (map as BsvMapDetail).versions?.[0]?.hash;
if(!hash){ return prev; }
const newPlaylistMaps = { ...prev };
delete newPlaylistMaps[(map as BsmLocalMap).hash];
return newPlaylistMaps;
}));
const renderBsvMapItem = useCallback((bsvMap: BsvMapDetail) => {
const mapHash = getHashOfMap(bsvMap);
const isSelected$ = availabledHashsSelected$.pipe(map(hashs => hashs.includes(mapHash)));
const isOwned$ = playlistMaps$.pipe(map(playlistMaps => !!playlistMaps[mapHash]));
return renderMapItem(bsvMap, () => handleOnClickMap({
map: bsvMap,
mapsSource$: bsvMaps$,
selectedHashs$: availabledHashsSelected$,
noKeyPressedFallBack: () => playlistMaps$.next(Object.assign({[mapHash]: bsvMap}, playlistMaps$.value))
}), isSelected$, isOwned$);
}, []);
const renderList = <T, >(maps: T[], render: (item: T) => JSX.Element) => {
const renderPlaylistMapItem = useCallback((playlistMap: (BsmLocalMap|BsvMapDetail|SongDetails)) => {
const mapHash = getHashOfMap(playlistMap);
const isSelected$ = playlistHashsSelected$.pipe(map(hashs => hashs.includes(mapHash)));
return renderMapItem(playlistMap, () => handleOnClickMap({
map: playlistMap,
mapsSource$: playlistMaps$.pipe(map(Object.values)),
selectedHashs$: playlistHashsSelected$,
noKeyPressedFallBack: () => playlistMaps$.next(Object.fromEntries(Object.entries(playlistMaps$.value).filter(([hash]) => hash !== mapHash)))
}), isSelected$);
}, []);
const renderList = <T, >(maps: T[], render: (item: T) => JSX.Element, scrollEndHandler?: VirtualScrollEndHandler) => {
return (
<VirtualScroll
classNames={{
mainDiv: "bg-theme-1 rounded-md size-full min-w-0 overflow-hidden",
rows: "my-2.5 px-2.5"
}}
items={maps}
itemHeight={110}
maxColumns={1}
renderItem={render}
/>
<div className="overflow-hidden size-full">
<VirtualScroll
classNames={{
mainDiv: "bg-theme-1 rounded-md size-full min-w-0",
rows: "my-2.5 px-2.5"
}}
items={maps}
itemHeight={110}
maxColumns={1}
renderItem={render}
scrollEnd={scrollEndHandler}
/>
</div>
)
}
const addMapsToPlaylist = useCallback(() => {
const tmpLocalMaps = availableMapsSource === 0 ? (localMaps$.value ?? []) : (bsvMaps$.value ?? [])
const mapsToAdd = availabledHashsSelected$.value?.length ? (
availabledHashsSelected$.value.map(hash => tmpLocalMaps.find(map => getHashOfMap(map) === hash)).filter(Boolean)
) : tmpLocalMaps;
const newPlaylistMaps = {...playlistMaps$.value} ?? {};
mapsToAdd.forEach(map => newPlaylistMaps[getHashOfMap(map)] = map);
playlistMaps$.next(newPlaylistMaps);
availabledHashsSelected$.next([]);
}, [availableMapsSource]);
const removeMapsFromPlaylist = useCallback(() => {
const hashsToRemove = playlistHashsSelected$.value?.length ? (
playlistHashsSelected$.value
) : Object.keys(playlistMaps$.value ?? {}).filter(hash => playlistMaps$.value[hash]);
const newPlaylistMaps = {...playlistMaps$.value};
hashsToRemove.forEach(hash => { delete newPlaylistMaps[hash]; });
playlistMaps$.next(newPlaylistMaps);
playlistHashsSelected$.next([]);
}, []);
const handleSortChange = (newSort: BsvSearchOrder) => {
setBsvSortOrder(() => newSort);
bsvMaps$.next([]);
setBsvSearchParams(() => ({ ...bsvSearchParams, sortOrder: newSort }));
};
const loadMoreBsvMaps = () => {
if(bsvLoading){ return; }
setBsvSearchParams(prev => ({ ...prev, page: prev.page + 1 }));
};
const handleNewSearch = () => {
if(availableMapsSource === 0){ return; }
bsvMaps$.next([]);
setBsvSearchParams(() => ({ page: 0, filter: availableMapsFilter, q: availableMapsSearch, sortOrder: bsvSearchOrder}));
}
return (
<div className="w-screen h-screen max-h-[calc(100vh-2rem)] max-w-[55rem] lg:max-w-[66rem] xl:max-w-[77rem] bg-theme-3 p-4 rounded-md">
{(() => {
if(!playlistMaps || !maps){
if(!playlistMaps || !localMaps){
return <div className="flex items-center justify-center w-full h-full">Loading...</div>
}
else{
@@ -140,29 +292,47 @@ export const EditPlaylistModal: ModalComponent<LocalBPListsDetails, Props> = ({
<div className="size-full flex flex-col justify-between gap-3">
<header>header</header>
<div className="grow flex flex-row min-h-0 gap-2.5">
<div className="flex flex-col grow basis-0">
<div className="h-8 flex flex-row gap-2 w-full mb-1.5">
<BsmSelect className="bg-theme-1 h-full rounded-full text-center" options={[{ text: "Local", value: 0 }, { text: "BeatSaver", value: 1 }]} onChange={setAvailableMapsSource}/>
<input className="h-full bg-light-main-color-1 dark:bg-main-color-1 rounded-full px-2 grow pb-0.5" type="text" placeholder={t("pages.version-viewer.maps.search-bar.search-placeholder")} value={availableMapsSearch} onChange={e => setAvailableMapsSearch(() => e.target.value)} />
<BsmDropdownButton className="h-full relative z-[1] flex justify-center" buttonClassName="flex items-center justify-center h-full rounded-full px-2 py-1" icon="filter" text="pages.version-viewer.maps.search-bar.filters-btn" textClassName="whitespace-nowrap" withBar={false}>
<FilterPanel className="absolute top-[calc(100%+3px)] origin-top w-[500px] h-fit p-2 rounded-md shadow-md shadow-black -translate-x-1/4 lg:translate-x-0" filter={availableMapsFilter} onChange={setAvailableMapsFilter}/>
<div className="flex flex-col grow basis-0 min-w-0">
<div className="h-8 flex flex-row gap-2 w-full mb-1.5 min-w-0">
<BsmSelect className="bg-theme-1 h-full rounded-full text-center pb-0.5" options={[{ text: "Installée(s)", value: 0 }, { text: "BeatSaver", value: 1 }]} onChange={setAvailableMapsSource}/>
<input className="h-full bg-light-main-color-1 dark:bg-main-color-1 rounded-full px-2 grow pb-0.5 min-w-0" type="text" placeholder={t("pages.version-viewer.maps.search-bar.search-placeholder")} value={availableMapsSearch} onChange={e => setAvailableMapsSearch(() => e.target.value)} />
{availableMapsSource === 1 && (
<BsmButton className="h-full aspect-square z-[1] flex justify-center p-1 rounded-full min-w-0 shrink-0" icon="search" withBar={false}/>
)}
<BsmDropdownButton ref={filterContainerRef} className="h-full aspect-square relative z-[1] flex justify-center" buttonClassName="flex items-center justify-center h-full rounded-full p-1 !bg-light-main-color-1 dark:!bg-main-color-1" icon="filter" textClassName="whitespace-nowrap" withBar={false}>
<FilterPanel className="absolute top-[calc(100%+3px)] origin-top w-[500px] h-fit p-2 rounded-md shadow-md shadow-black -translate-x-1/4 lg:translate-x-0" filter={availableMapsFilter} onChange={setAvailableMapsFilter} onApply={availableMapsSource === 1 && handleNewSearch} onClose={() => filterContainerRef.current.close()}/>
</BsmDropdownButton>
{availableMapsSource === 1 && (
<BsmSelect className="bg-theme-1 h-full rounded-full text-center pb-0.5 min-w-0 lg:min-w-fit" options={sortOptions} onChange={handleSortChange}/>
)}
</div>
{renderList(maps.filter(map => {
if(playlistMaps[map.hash]){ return false; }
return isLocalMapFitMapFilter({ map, filter: availableMapsFilter, search: availableMapsSearch });
}), renderAvailableMapItem)}
<div className="w-full h-4 flex justify-end">
</div>
{availableMapsSource === 0 ? (
renderList(localMaps.filter(map => {
if(playlistMaps?.[map.hash]){ return false; }
return isMapFitFilter({ map, filter: availableMapsFilter, search: availableMapsSearch });
}), renderAvailableMapItem)
) : (
renderList(bsvMaps, renderBsvMapItem, { onScrollEnd: loadMoreBsvMaps })
)}
<div className="w-full h-4 flex justify-end"/>
</div>
<div className="shrink-0 flex flex-col gap-2.5 pb-4 pt-10">
<button className="grow w-9 rounded-md cursor-pointer" style={{backgroundColor: color}} type="button"><ChevronTopIcon className="origin-center rotate-90"/></button>
<button className="grow w-9 rounded-md cursor-pointer" style={{backgroundColor: color}} type="button"><ChevronTopIcon className="origin-center -rotate-90"/></button>
<Tippy content="Ajouter à la playlist" theme="default" placement="left">
<button className="grow w-9 rounded-md cursor-pointer transition-transform duration-150 hover:brightness-110 active:scale-95" style={{backgroundColor: color}} type="button" onClick={addMapsToPlaylist}>
<ChevronTopIcon className="origin-center rotate-90"/>
</button>
</Tippy>
<Tippy content="Enlever de la playlist" theme="default" placement="right">
<button className="grow w-9 rounded-md cursor-pointer transition-transform duration-150 hover:brightness-110 active:scale-95" style={{backgroundColor: color}} type="button" onClick={removeMapsFromPlaylist}>
<ChevronTopIcon className="origin-center -rotate-90"/>
</button>
</Tippy>
</div>
<div className="flex flex-col grow basis-0">
<div className="h-8 flex flex-row gap-2 w-full mb-1.5">
<input className="h-full bg-light-main-color-1 dark:bg-main-color-1 rounded-full px-2 grow pb-0.5" type="text" placeholder={t("pages.version-viewer.maps.search-bar.search-placeholder")} value={playlistMapsSearch} onChange={e => setPlaylistMapsSearch(() => e.target.value)} />
<BsmDropdownButton className="h-full relative z-[1] flex justify-center" buttonClassName="flex items-center justify-center h-full rounded-full px-2 py-1" icon="filter" text="pages.version-viewer.maps.search-bar.filters-btn" textClassName="whitespace-nowrap" withBar={false}>
<BsmDropdownButton className="h-full aspect-square relative z-[1] flex justify-center" buttonClassName="flex items-center justify-center h-full rounded-full p-1 !bg-light-main-color-1 dark:!bg-main-color-1" icon="filter" textClassName="whitespace-nowrap" withBar={false}>
<FilterPanel className="absolute top-[calc(100%+3px)] origin-top w-[500px] h-fit p-2 rounded-md shadow-md shadow-black -translate-x-[40%]" filter={playlistMapsFilter} onChange={setPlaylistMapsFilter}/>
</BsmDropdownButton>
</div>
@@ -14,7 +14,7 @@ export type VirtualScrollClassNames = {
rows?: string;
}
type ScrollEndHandler = {
export type VirtualScrollEndHandler = {
onScrollEnd: () => void;
margin?: number;
}
@@ -29,7 +29,7 @@ type Props<T = unknown> = {
items: T[];
renderItem: (item: T) => JSX.Element;
rowKey?: (rowItems: T[]) => Key;
scrollEnd?: ScrollEndHandler;
scrollEnd?: VirtualScrollEndHandler;
}
export function VirtualScroll<T = unknown>({ className, classNames, minItemWidth, maxColumns, minColumns, itemHeight, items, scrollEnd, renderItem, rowKey}: Props<T>) {
+1
View File
@@ -1 +1,2 @@
export type FieldRequired<T, K extends keyof T> = Partial<T> & Required<Pick<T, K>>; // All fields of T are optional except for K
export type ObjectValues<T> = T[keyof T]; // All values of T
@@ -50,6 +50,7 @@ export abstract class MapItemComponentPropsMapper {
diffs: MapItemComponentPropsMapper.extractMapDiffs({ rawMapInfo: map.rawInfo, songDetails: map.songDetails }),
mapId: map.songDetails?.id,
ranked: map.songDetails?.ranked,
blRanked: map.songDetails?.rankedBL,
autorId: map.songDetails?.uploader.id,
likes: map.songDetails?.upVotes,
createdAt: map.songDetails?.uploadedAt,
@@ -69,6 +70,7 @@ export abstract class MapItemComponentPropsMapper {
likes: map.stats.upvotes,
mapId: map.id,
ranked: map.ranked,
blRanked: map.blRanked,
title: map.name,
songAutor: map.metadata.songAuthorName,
diffs: MapItemComponentPropsMapper.extractMapDiffs({ bsvMap: map }),
@@ -87,6 +89,7 @@ export abstract class MapItemComponentPropsMapper {
likes: song.upVotes,
mapId: song.id,
ranked: song.ranked,
blRanked: song.rankedBL,
title: song.name,
diffs: MapItemComponentPropsMapper.extractMapDiffs({ songDetails: song }),
callBackParam: song
@@ -1,3 +1,4 @@
import { ObjectValues } from "shared/helpers/type.helpers";
import { SongDetailDiffCharactertistic, SongDiffName } from "./song-details-cache/song-details-cache.model";
export interface BsvMapDetail {
@@ -13,6 +14,9 @@ export interface BsvMapDetail {
name: string;
qualified: boolean;
ranked: boolean;
blQualified: boolean
blRanked: boolean
declaredAi: ObjectValues<typeof BsvDeclaredAi>;
stats: BsvMapStats;
tags: MapTag[];
updatedAt: BsvInstant;
@@ -212,6 +216,14 @@ export enum MapSpecificity {
}
//[ Admin, Uploader, SageScore, None ]
export const BsvDeclaredAi = {
Admin: "Admin",
Uploader: "Uploader",
SageScore: "SageScore",
None: "None"
} as const;
export interface MapFilter {
automapper?: boolean;
chroma?: boolean;