[feature-136] models management almost finished, still need to add translations

This commit is contained in:
MathieuG-P
2023-05-19 19:06:59 +02:00
parent 76bdde0fe8
commit bdc893d8a5
21 changed files with 308 additions and 128 deletions
@@ -5,9 +5,8 @@ import { ModalComponent } from "renderer/services/modale.service";
import { BSVersion } from "shared/bs-version.interface";
import BeatWaitingImg from "../../../../../../assets/images/apngs/beat-waiting.png";
import BeatConflictImg from "../../../../../../assets/images/apngs/beat-conflict.png";
import { BsmLocalModel } from "shared/models/models/bsm-local-model.interface";
import { MSGetQuery, MSGetSort, MSGetSortDirection, MSModel, MSModelPlatform, MSModelType } from "shared/models/models/model-saber.model";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useState } from "react";
import { ModelItem } from "renderer/components/models-management/model-item.component";
import { useService } from "renderer/hooks/use-service.hook";
import { ModelDownload, ModelsDownloaderService } from "renderer/services/models-management/models-downloader.service";
@@ -21,11 +20,10 @@ import { MODEL_TYPES, MS_GET_QUERY_SORTS } from "shared/models/models/constants"
import { useOnUpdate } from "renderer/hooks/use-on-update.hook";
import equal from "fast-deep-equal";
import { catchError, of } from "rxjs";
import { BsmIcon } from "renderer/components/svgs/bsm-icon.component";
import Tippy from "@tippyjs/react";
import { useThemeColor } from "renderer/hooks/use-theme-color.hook";
import { BsmLocalModel } from "shared/models/models/bsm-local-model.interface";
export const DownloadModelsModal: ModalComponent<void, {version: BSVersion, type: MSModelType}> = ({resolver, data: { version, type }}) => {
export const DownloadModelsModal: ModalComponent<void, {version: BSVersion, type: MSModelType, owned: BsmLocalModel[]}> = ({resolver, data: { version, type, owned }}) => {
const modelsDownloader = useService(ModelsDownloaderService);
const modelSaber = useService(ModelSaberService);
@@ -42,34 +40,46 @@ export const DownloadModelsModal: ModalComponent<void, {version: BSVersion, type
})));
const t = useTranslation();
const color = useThemeColor("first-color");
const currentDownload = useObservable(modelsDownloader.currentDownload$(), null);
const downloadQueue = useObservable(modelsDownloader.getQueue$(), []);
const [msModels, msModels$] = useBehaviorSubject<MSModel[]>([]);
const isOnline = useObservable(os.isOnline$, true);
const [error, error$] = useBehaviorSubject(false);
const [isLoading, isLoading$] = useBehaviorSubject(false);
console.log(msModels);
const [ownedModels, setOwnedModels] = useState<BsmLocalModel[]>(owned ?? []);
const [currentType, currentType$] = useBehaviorSubject<MSModelType>(type);
const [currentSort, currentSort$] = useBehaviorSubject<MSGetSort>(MSGetSort.Date);
const [searhInput, searhInput$] = useBehaviorSubject("");
const [getQuery, getQuery$] = useBehaviorSubject<MSGetQuery>({ type: currentType, platform: MSModelPlatform.PC, start: 0, end: 25, sort: currentSort, sortDirection: MSGetSortDirection.Descending });
useOnUpdate(() => {
const sub = modelsDownloader.onModelsDownloaded((model => {
if(!equal(model.version, version)) { return; }
setOwnedModels(prev => [...prev ?? [], model]);
}));
return () => sub.unsubscribe();
});
useOnUpdate(() => {
error$.next(false);
isLoading$.next(true);
const sub = modelSaber.searchModels(getQuery).pipe(catchError(() => {
error$.next(true);
return of([]);
})).subscribe(models => msModels$.next([...msModels, ...models]));
})).subscribe(models => {
msModels$.next([...msModels, ...models]);
isLoading$.next(false);
});
return () => sub.unsubscribe();
}, [getQuery])
useOnUpdate(() => {
msModels$.next([]);
getQuery$.next({
...getQuery$.value,
start: 0,
@@ -81,7 +91,6 @@ export const DownloadModelsModal: ModalComponent<void, {version: BSVersion, type
}, [currentType, currentSort]);
const loadMore = () => {
console.log("load more");
const currentQuery = getQuery;
currentQuery.start += 25;
currentQuery.end += 25;
@@ -90,8 +99,6 @@ export const DownloadModelsModal: ModalComponent<void, {version: BSVersion, type
const search = () => {
const filters = searhInput.split(" ").join(",");
const getQuery: MSGetQuery = {
start: 0, end: 25,
type: currentType,
@@ -105,12 +112,24 @@ export const DownloadModelsModal: ModalComponent<void, {version: BSVersion, type
}
const handleDownloadModel = useCallback((model: MSModel) => {
modelsDownloader.addModelToDownload({...model, version});
modelsDownloader.addModelToDownload({model, version});
}, []);
const handleCancelDownload = useCallback((model: MSModel) => {
modelsDownloader.removeFromDownloadQueue({model, version});
}, []);
const modelPendingDownload = (model: MSModel) => {
return downloadQueue.some(download => download.model.id === model.id && equal(download.version, version));
}
const isModelOwned = (model: MSModel) => {
return !!ownedModels.some(owned => owned.hash === model.hash);
}
const filterTipsHTML = useConstant(() => (
<div className="w-fit flex">
<table className="w-fit whitespace-nowrap grow shrink-0 text-base">
<table className="w-fit whitespace-nowrap grow shrink-0 text-sm">
<tbody>
<tr className="font-bold"><td className="mr-3 block">Tag</td><td>Description</td></tr>
<tr><td className="mr-3 block">author:</td><td>Only show models by the specified author.</td></tr>
@@ -127,7 +146,7 @@ export const DownloadModelsModal: ModalComponent<void, {version: BSVersion, type
return (
<form className="text-gray-800 dark:text-gray-200 flex flex-col max-w-[95vw] w-[970px] h-[85vh] gap-3" onSubmit={e => {e.preventDefault(); search()}}>
<div className="flex h-9 gap-2 shrink-0">
<BsmSelect className="bg-light-main-color-1 dark:bg-main-color-1 rounded-full px-1 pb-0.5 text-center" options={modelTypesOptions} onChange={(value) => currentType$.next(value)}/>
<BsmSelect className="bg-light-main-color-1 dark:bg-main-color-1 rounded-full px-1 pb-0.5 text-center" options={modelTypesOptions} selected={currentType} onChange={(value) => currentType$.next(value)}/>
<div className="h-ful grow relative flex justify-center items-center">
<input className="h-full w-full bg-light-main-color-1 dark:bg-main-color-1 rounded-full px-2 pb-0.5" type="text" name="" id="" placeholder="TODO TRANSLATE Rechercher un modèle" value={searhInput} onChange={e => searhInput$.next(e.target.value)}/>
<Tippy placement="bottom" content={filterTipsHTML} allowHTML={true} maxWidth={Infinity}>
@@ -153,8 +172,9 @@ export const DownloadModelsModal: ModalComponent<void, {version: BSVersion, type
key={model.id}
{...model}
callbackValue={model}
isDownloading={equal(currentDownload, {...model, version} as ModelDownload)}
onDownload={handleDownloadModel}
isDownloading={equal(currentDownload, {model, version} as ModelDownload)}
onDownload={!isModelOwned(model) ? handleDownloadModel: undefined}
onCancelDownload={modelPendingDownload(model) ? handleCancelDownload : undefined}
/>
))}
<motion.span onViewportEnter={loadMore} className="block w-full h-8"/>
@@ -59,12 +59,10 @@ function modelItem<T = unknown>(props: Props<T>) {
return [...new Set(props.tags)];
})();
console.log(thumbnailUrl, "la");
const actionButtons = (): {text: string, icon: BsmIconType, action: () => void}[] => {
const buttons: {text: string, icon: BsmIconType, action: () => void}[] = [];
const actionButtons = (): {text: string, icon: BsmIconType, action: () => void, iconColor?: string}[] => {
const buttons: {text: string, icon: BsmIconType, action: () => void, iconColor?: string}[] = [];
if(props.onDownload){
if(props.onDownload && !props.onCancelDownload){
buttons.push({ text: "Download", icon: "download", action: () => props.onDownload(props.callbackValue) }); // TODO TRANSLATE
}
@@ -73,7 +71,7 @@ function modelItem<T = unknown>(props: Props<T>) {
}
if(props.onCancelDownload){
buttons.push({ text: "Cancel download", icon: "cross", action: () => props.onCancelDownload(props.callbackValue) }); // TODO TRANSLATE
buttons.push({ text: "Cancel download", icon: "cross", action: () => props.onCancelDownload(props.callbackValue), iconColor: "red" }); // TODO TRANSLATE
}
return buttons;
@@ -91,18 +89,18 @@ function modelItem<T = unknown>(props: Props<T>) {
// TODO TRANSLATE ALL TEXT
<motion.li className={`relative flex-grow min-w-[14rem] h-56 cursor-pointer ${props.className ?? ""}`} onHoverStart={() => setHovered(() => true)} onHoverEnd={() => setHovered(() => false)} onClick={props.onClick}>
<GlowEffect visible={props.selected || hovered}/>
<div className="absolute top-0 left-0 w-full h-full rounded-lg overflow-hidden blur-none bg-black">
<div className="absolute top-0 left-0 w-full h-full rounded-lg overflow-hidden blur-none bg-black shadow-sm shadow-black">
<BsmImage className={`absolute top-0 left-0 w-full h-full object-cover ${props.type === MSModelType.Avatar ? "object-top" : ""}`} image={thumbnailUrl} placeholder={defaultImage} loading="lazy"/>
<div className="absolute top-0 right-0 h-full w-0 flex flex-col items-end gap-1 pt-1.5 pr-1.5">
{!props.isDownloading ? (
actionButtons().map((button, index) => (
<BsmButton key={index} className="w-7 h-7 p-1 rounded-md transition-transform duration-150 shadow-black shadow-sm" style={{transitionDelay: `${index * 50}ms`, transform: hovered ? "translate(0%)" : "translate(150%)"}} icon={button.icon} onClick={e => {e.stopPropagation(); e.preventDefault(); button.action();}} withBar={false}/>
<BsmButton key={index} className="w-7 h-7 p-1 rounded-md transition-transform duration-150 shadow-black shadow-sm" style={{transitionDelay: `${index * 50}ms`, transform: hovered ? "translate(0%)" : "translate(150%)"}} icon={button.icon} iconColor={button.iconColor} onClick={e => {e.stopPropagation(); e.preventDefault(); button.action();}} withBar={false}/>
))
): (
<BsmBasicSpinner className="w-7 h-7 p-1 rounded-md bg-main-color-2 flex items-center justify-center shadow-black shadow-sm" spinnerClassName="brightness-200" thikness="3.5px" style={{color}}/>
)}
</div>
<motion.div className="absolute cursor-default top-[80%] left-0 w-full h-full p-2 flex flex-col gap-1.5 bg-main-color-3 bg-opacity-60 backdrop-blur-md transition-all delay-150 hover:top-0" onClick={e =>{e.stopPropagation(); e.preventDefault()}}>
<motion.div className="absolute cursor-default top-[80%] left-0 w-full h-full px-2 pt-2.5 flex flex-col gap-1.5 bg-main-color-3 bg-opacity-60 backdrop-blur-md transition-all delay-150 hover:top-0" onClick={e =>{e.stopPropagation(); e.preventDefault()}}>
<BsmLink className={`block w-fit max-w-full overflow-hidden font-bold whitespace-nowrap text-ellipsis ${props.id ? "cursor-pointer hover:underline" : ""}`} href={modelPageUrl}>{props.name}</BsmLink>
<BsmLink className={`block w-fit max-w-full overflow-hidden whitespace-nowrap text-ellipsis brightness-200 ${authorPageUrl ? "cursor-pointer hover:underline" : ""}`} style={{color}} href={authorPageUrl}>{props.author ?? ''}</BsmLink>
<ul className="flex flex-row flex-wrap gap-1">
@@ -18,6 +18,8 @@ import { BsmButton } from "../shared/bsm-button.component";
import equal from "fast-deep-equal";
import { VersionLinkerAction } from "renderer/services/version-folder-linker.service";
import { MODEL_TYPE_FOLDERS } from "shared/models/models/constants";
import { useService } from "renderer/hooks/use-service.hook";
import { ModelsDownloaderService } from "renderer/services/models-management/models-downloader.service";
type Props = {
className?: string,
@@ -29,13 +31,13 @@ type Props = {
export const ModelsGrid = forwardRef(({className, version, type, search, active}: Props, forwardRef) => {
const modelsManager = useConstant(() => ModelsManagerService.getInstance());
const modelsManager = useService(ModelsManagerService);
const modelsDownloader = useService(ModelsDownloaderService);
const ref = useRef();
const isVisible = useInView(ref, {once: true, amount: .1});
const [models, setModelsLoadObservable,, setModels] = useSwitchableObservable<BsmLocalModel[]>();
const [progress$, setProgress$] = useState(new BehaviorSubject(0).asObservable());
const progress$ = useConstant(() => new BehaviorSubject(0));
const [modelsSelected, modelsSelected$] = useBehaviorSubject<BsmLocalModel[]>([]);
const isLoading = !models;
@@ -63,29 +65,45 @@ export const ModelsGrid = forwardRef(({className, version, type, search, active}
}), [modelsSelected, models]);
useOnUpdate(() => {
if(!isVisible || !active){ return; }
if(!active){ return; }
if(models && models.length){ return; }
loadModels();
}, [active]);
useOnUpdate(() => {
if(!active){
setModels(() => null);
}
else {
loadModels();
}
const onLinkStateChangeCb = (action: VersionLinkerAction) => {
if(!equal(version, action.version) || !action.relativeFolder.includes(MODEL_TYPE_FOLDERS[type])){ return; }
loadModels();
}
const sub = modelsDownloader.onModelsDownloaded(localModel => {
if(localModel.type !== type || !equal(localModel.version, version)){ return; }
setModels(models => [localModel, ...models ?? []]);
});
modelsManager.onModelsFolderLinked(onLinkStateChangeCb);
modelsManager.onModelsFolderUnlinked(onLinkStateChangeCb);
return () => {
modelsManager.removeModelsFolderLinkedListener(onLinkStateChangeCb);
modelsManager.removeModelsFolderUnlinkedListener(onLinkStateChangeCb);
sub.unsubscribe();
}
}, [version, isVisible, type]);
}, [version, type]);
const loadModels = () => {
const modelsObs$ = modelsManager.$getModels(type, version);
setModels(() => null);
setModelsLoadObservable(() => modelsObs$.pipe(map(models => models?.extra), distinctUntilChanged()));
setProgress$(modelsObs$.pipe(map(models => Math.floor((models.current / models.total) * 100)), startWith(0), distinctUntilChanged()))
setModelsLoadObservable(() => modelsObs$.pipe(map(models => models?.data), distinctUntilChanged()));
modelsObs$.pipe(map(models => Math.floor((models.current / models.total) * 100)), startWith(0), distinctUntilChanged()).subscribe({next: v => progress$.next(v)});
}
const handleModelClick = (model: BsmLocalModel) => {
@@ -159,7 +177,7 @@ export const ModelsGrid = forwardRef(({className, version, type, search, active}
{filtredModels().map(localModel => (
<ModelItem
{...localModel?.model}
key={localModel.model?.hash ?? localModel.hash}
key={localModel.path}
hash={localModel.model?.hash ?? localModel.hash}
path={localModel.path}
type={localModel.type}
@@ -178,4 +196,4 @@ export const ModelsGrid = forwardRef(({className, version, type, search, active}
{renderContent()}
</div>
)
})
});
@@ -1,42 +1,57 @@
import { BSVersion } from "shared/bs-version.interface";
import { useRef, useState, MutableRefObject } from "react";
import { useRef, useState } from "react";
import { ModelsTabsNavbar } from "./models-tabs-navbar.component";
import { ModelsGrid } from "./models-grid.component";
import { MSModelType } from "shared/models/models/model-saber.model";
import { BsmDropdownButton, DropDownItem } from "../shared/bsm-dropdown-button.component";
import { useConstant } from "renderer/hooks/use-constant.hook";
import { ModelsManagerService } from "renderer/services/models-management/models-manager.service";
import { BsmLocalModel } from "shared/models/models/bsm-local-model.interface";
import { BsmButton } from "../shared/bsm-button.component";
import { useService } from "renderer/hooks/use-service.hook";
import { ModelsDownloaderService } from "renderer/services/models-management/models-downloader.service";
import { useOnUpdate } from "renderer/hooks/use-on-update.hook";
import { NotificationService } from "renderer/services/notification.service";
import { ConfigurationService } from "renderer/services/configuration.service";
export function ModelsPanel({version}: {version?: BSVersion}) {
export function ModelsPanel({version, isActive, goToMods}: {version?: BSVersion, isActive: boolean, goToMods: () => void}) {
const modelsManager = useService(ModelsManagerService);
const modelDownloader = useService(ModelsDownloaderService);
const modelDownloader = useService(ModelsDownloaderService);
const notification = useService(NotificationService);
const config = useService(ConfigurationService);
const [avatarsRef, sabersRef, platformsRef, bloqsRef] = [useRef(null), useRef(null), useRef(null), useRef(null)];
const ref = useRef();
const modelsGridRefs = [useRef(null), useRef(null), useRef(null), useRef(null)];
const [modelTypeTab, setModelTypeTab] = useState<MSModelType>(MSModelType.Avatar);
const [currentTabIndex, setCurrentTabIndex] = useState<number>(0);
const [search, setSearch] = useState<string>("");
const getActiveTabRef = () => [avatarsRef, sabersRef, platformsRef, bloqsRef][currentTabIndex];
useOnUpdate(() => {
if(!isActive){ return; }
//if(config.get("prevented-for-mods-models")){ return; }
config.set("prevented-for-mods-models", true);
notification.notifyWarning({ title: "Mods needed", desc: "Be sure to have the mods installed to use models in BeatSaber", actions: [{id: "0", title: "Go to mods"}], duration: 9_000 }).then(res => {
if(res !== "0"){ return; }
goToMods();
});
}, [isActive]);
const exportModels = () => {
const selectedModels = [avatarsRef, sabersRef, platformsRef, bloqsRef].map(ref =>(ref.current?.getSelectedModels() as BsmLocalModel[])).flat()
const selectedModels = modelsGridRefs.map(ref =>(ref.current?.getSelectedModels() as BsmLocalModel[])).flat()
modelsManager.exportModels(selectedModels, version);
}
const deleteModels = () => {
const activeTab = getActiveTabRef();
const activeTab = modelsGridRefs[currentTabIndex];
activeTab.current?.deleteSelectedModels();
}
const openDownloadModal = () => {
modelDownloader.openDownloadModelsModal(version, modelTypeTab);
const allLoadedModels = modelsGridRefs.map(ref =>(ref.current?.getModels() as BsmLocalModel[])).flat();
modelDownloader.openDownloadModelsModal(version, modelTypeTab, allLoadedModels);
}
const threeDotsItems: DropDownItem[] = [
@@ -45,7 +60,7 @@ export function ModelsPanel({version}: {version?: BSVersion}) {
];
return (
<div className="w-full h-full flex flex-col items-center justify-center">
<div ref={ref} className="w-full h-full flex flex-col items-center justify-center">
<div className="w-full shrink-0 flex h-9 justify-center px-40 gap-2 mb-3 text-main-color-1 dark:text-white">
<BsmButton className="flex items-center justify-center w-fit rounded-full px-2 py-1 font-bold" icon="add" text="misc.add" typeColor="primary" withBar={false} onClick={e => {e.preventDefault(); openDownloadModal()}}/>
<div className="h-full rounded-full bg-light-main-color-2 dark:bg-main-color-2 grow p-[6px]">
@@ -57,10 +72,10 @@ export function ModelsPanel({version}: {version?: BSVersion}) {
<ModelsTabsNavbar className="flex-shrink-0" version={version} tabIndex={currentTabIndex} onTabChange={(index, tab) => {setModelTypeTab(() => tab.extra), setCurrentTabIndex(() => index)}}/>
<div className="flex-grow h-full flex flex-col transition-all duration-300" style={{translate: `0 ${0 - currentTabIndex * 100}%`}}>
<ModelsGrid ref={avatarsRef} version={version} type={MSModelType.Avatar} active={modelTypeTab === MSModelType.Avatar} search={search}/>
<ModelsGrid ref={sabersRef} version={version} type={MSModelType.Saber} active={modelTypeTab === MSModelType.Saber} search={search}/>
<ModelsGrid ref={platformsRef} version={version} type={MSModelType.Platfrom} active={modelTypeTab === MSModelType.Platfrom} search={search}/>
<ModelsGrid ref={bloqsRef} version={version} type={MSModelType.Bloq} active={modelTypeTab === MSModelType.Bloq} search={search}/>
<ModelsGrid ref={modelsGridRefs[0]} version={version} type={MSModelType.Avatar} active={isActive && modelTypeTab === MSModelType.Avatar} search={search}/>
<ModelsGrid ref={modelsGridRefs[1]} version={version} type={MSModelType.Saber} active={isActive && modelTypeTab === MSModelType.Saber} search={search}/>
<ModelsGrid ref={modelsGridRefs[2]} version={version} type={MSModelType.Platfrom} active={isActive && modelTypeTab === MSModelType.Platfrom} search={search}/>
<ModelsGrid ref={modelsGridRefs[3]} version={version} type={MSModelType.Bloq} active={isActive && modelTypeTab === MSModelType.Bloq} search={search}/>
</div>
</div>
</div>
@@ -1,23 +1,25 @@
import equal from "fast-deep-equal";
import { ComponentProps } from "react"
import { useTranslation } from "renderer/hooks/use-translation.hook"
type Props<T> = Omit<ComponentProps<"select">, "onChange"> & {
options?: BsmSelectOption<T>[],
selected?: T,
onChange?: (value: T) => void,
}
export function BsmSelect<T = unknown>({className, style, options, onChange}: Props<T>) {
export function BsmSelect<T = unknown>(props: Props<T>) {
const t = useTranslation();
const handleChange: ComponentProps<"select">["onChange"] = (e) => {
e.preventDefault();
onChange?.(options?.[parseInt(e.target.value)]?.value);
props.onChange?.(props.options?.[parseInt(e.target.value)]?.value);
}
return (
<select className={className} style={style} onChange={handleChange}>
{options && options.map((option, index) => (
<select {...props} onChange={handleChange} defaultValue={props.options?.findIndex(opt => equal(opt.value, props.selected))}>
{props.options && props.options.map((option, index) => (
<option key={index} value={index}>{t(option.text)}</option>
))}
</select>