mirror of
https://github.com/Zagrios/bs-manager.git
synced 2026-07-03 14:08:25 +02:00
[feature-107] progress on the modal for creating and editing playlists
This commit is contained in:
@@ -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>
|
||||
|
||||
+242
-72
@@ -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 +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;
|
||||
|
||||
Reference in New Issue
Block a user