diff --git a/src/main/services/additional-content/local-maps-manager.service.ts b/src/main/services/additional-content/local-maps-manager.service.ts index 0fdf040c..b30349aa 100644 --- a/src/main/services/additional-content/local-maps-manager.service.ts +++ b/src/main/services/additional-content/local-maps-manager.service.ts @@ -7,7 +7,7 @@ import { InstallationLocationService } from "../installation-location.service"; import { UtilsService } from "../utils.service"; import crypto from "crypto"; import { lstatSync } from "fs"; -import { copy, createReadStream, ensureDir, realpath, unlink } from "fs-extra"; +import { copy, createReadStream, ensureDir, pathExists, realpath, unlink } from "fs-extra"; import StreamZip from "node-stream-zip"; import { RequestService } from "../request.service"; import sanitize from "sanitize-filename"; @@ -255,10 +255,12 @@ export class LocalMapsManagerService { const mapFolderName = sanitize(`${map.id}-${map.name}`); const mapsFolder = await this.getMapsFolderPath(version); - const installedMap = await this.loadMapInfoFromPath(path.join(mapsFolder, mapFolderName)).catch(e => { - log.error("loadMapInfoFromPath", e); - return null; - }); + const mapPath = path.join(mapsFolder, mapFolderName); + + const installedMap = await pathExists(mapPath).then(exists => { + if(!exists){ return null; } + return this.loadMapInfoFromPath(mapPath); + }).catch(() => null); if(map.versions.every(version => version.hash === installedMap?.hash)) { return installedMap; @@ -270,8 +272,6 @@ export class LocalMapsManagerService { throw `Cannot download ${zipUrl}`; } - const mapPath = path.join(mapsFolder, mapFolderName); - await ensureFolderExist(mapPath); await zip.extract(null, mapPath); diff --git a/src/renderer/components/available-versions/available-versions-slider.component.tsx b/src/renderer/components/available-versions/available-versions-slider.component.tsx index 89255959..02f45b9f 100644 --- a/src/renderer/components/available-versions/available-versions-slider.component.tsx +++ b/src/renderer/components/available-versions/available-versions-slider.component.tsx @@ -8,7 +8,7 @@ import { useObservable } from "renderer/hooks/use-observable.hook"; export function AvailableVersionsSlider() { const versionManagerService = useService(BSVersionManagerService); - const availableVersions = useObservable(versionManagerService.availableVersions$); + const availableVersions = useObservable(() => versionManagerService.availableVersions$); const [yearIndex, setYearIndex] = useState(0); const availableYears = (() => { diff --git a/src/renderer/components/maps-mangement-components/link-button.component.tsx b/src/renderer/components/maps-mangement-components/link-button.component.tsx index bc8d1d6c..9526fceb 100644 --- a/src/renderer/components/maps-mangement-components/link-button.component.tsx +++ b/src/renderer/components/maps-mangement-components/link-button.component.tsx @@ -1,48 +1,56 @@ import { motion } from "framer-motion"; -import { forwardRef } from "react"; import { useThemeColor } from "renderer/hooks/use-theme-color.hook"; import { BsmIcon } from "../svgs/bsm-icon.component"; import { useTranslation } from "renderer/hooks/use-translation.hook"; +import { FolderLinkState } from "renderer/services/version-folder-linker.service"; -type Props = { +export type LinkBtnProps = { className?: string; title?: string; - linked?: boolean; - disabled?: boolean; - onClick?: () => void; + state: FolderLinkState; + onClick?: () => unknown; }; -export const LinkButton = motion( - forwardRef((props: Props, ref) => { - const t = useTranslation(); - const color = useThemeColor("first-color"); +export const LinkButton = ({className, title, state, onClick}: LinkBtnProps) => { + const t = useTranslation(); + + const color = useThemeColor("first-color"); + const disabled = state === FolderLinkState.Processing || state === FolderLinkState.Pending; - const linkedColor = (() => { - if (props.disabled) { - return "orange"; - } - if (props.linked) { + const btnColor = () => { + switch (state) { + case FolderLinkState.Linked: return color; - } - return "red"; - })(); + case FolderLinkState.Pending: + case FolderLinkState.Processing: + return "orange"; + case FolderLinkState.Unlinked: + return "red"; + default: + return "red"; + } + } - return ( -
{ - e.preventDefault(); - e.stopPropagation(); - !props.disabled && props.onClick?.(); - }} - style={{ pointerEvents: props.disabled ? "none" : "auto" }} - tabIndex={-1} - > - - -
- ); - }) -); + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + !disabled && onClick?.(); + } + + return ( + + + + + ); +} diff --git a/src/renderer/components/maps-mangement-components/local-maps-list-panel.component.tsx b/src/renderer/components/maps-mangement-components/local-maps-list-panel.component.tsx index e7af8ece..a6f66cf9 100644 --- a/src/renderer/components/maps-mangement-components/local-maps-list-panel.component.tsx +++ b/src/renderer/components/maps-mangement-components/local-maps-list-panel.component.tsx @@ -429,7 +429,7 @@ export const LocalMapsListPanel = forwardRef(({ version, className, filter, sear return (
- 108} itemCount={preppedMaps.length} itemData={preppedMaps} layout="vertical" style={{ scrollbarGutter: "stable both-edges" }} itemKey={(i, data) => data[i].map(map => map.hash).join()}> + 108} itemCount={preppedMaps.length} itemData={preppedMaps} layout="vertical" style={{ scrollbarGutter: "stable both-edges" }} itemKey={(i, data) => data[i].map(map => map.hash).join()}> {props => }
diff --git a/src/renderer/components/maps-mangement-components/map-item.component.tsx b/src/renderer/components/maps-mangement-components/map-item.component.tsx index b71ccd33..49242a6f 100644 --- a/src/renderer/components/maps-mangement-components/map-item.component.tsx +++ b/src/renderer/components/maps-mangement-components/map-item.component.tsx @@ -70,7 +70,7 @@ export const MapItem = memo(({ hash, title, autor, songAutor, coverUrl, songUrl, onDoubleClick: () => onDoubleClick?.(callBackParam), }); - const songPlaying = useObservable(audioPlayer.playing$.pipe(map(playing => playing && audioPlayer.src === songUrl))); + const songPlaying = useObservable(() => audioPlayer.playing$.pipe(map(playing => playing && audioPlayer.src === songUrl))); const previewUrl = mapId ? `https://allpoland.github.io/ArcViewer/?id=${mapId}` : null; const mapUrl = mapId ? `https://beatsaver.com/maps/${mapId}` : null; @@ -191,8 +191,8 @@ export const MapItem = memo(({ hash, title, autor, songAutor, coverUrl, songUrl,
- -
+ +

{title} @@ -240,7 +240,7 @@ export const MapItem = memo(({ hash, title, autor, songAutor, coverUrl, songUrl,

-
+
diff --git a/src/renderer/components/maps-mangement-components/maps-playlists-panel.component.tsx b/src/renderer/components/maps-mangement-components/maps-playlists-panel.component.tsx index db26ff29..3705f055 100644 --- a/src/renderer/components/maps-mangement-components/maps-playlists-panel.component.tsx +++ b/src/renderer/components/maps-mangement-components/maps-playlists-panel.component.tsx @@ -1,24 +1,22 @@ -import { DetailedHTMLProps, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { BSVersion } from "shared/bs-version.interface"; -import { TabNavBar } from "../shared/tab-nav-bar.component"; import { LocalMapsListPanel } from "./local-maps-list-panel.component"; import { BsmDropdownButton, DropDownItem } from "../shared/bsm-dropdown-button.component"; import { FilterPanel } from "./filter-panel.component"; import { MapFilter } from "shared/models/maps/beat-saver.model"; -import { useThemeColor } from "renderer/hooks/use-theme-color.hook"; import { MapsManagerService } from "renderer/services/maps-manager.service"; -import { motion, Variants } from "framer-motion"; import { MapsDownloaderService } from "renderer/services/maps-downloader.service"; import { BsmImage } from "../shared/bsm-image.component"; import wipGif from "../../../../assets/images/gifs/wip.gif"; -import { OsDiagnosticService } from "renderer/services/os-diagnostic.service"; -import { useObservable } from "renderer/hooks/use-observable.hook"; -import { BsmIcon } from "../svgs/bsm-icon.component"; import { useTranslation } from "renderer/hooks/use-translation.hook"; -import { LinkButton } from "./link-button.component"; -import { debounceTime } from "rxjs/operators"; -import { VersionFolderLinkerService, VersionLinkerActionListener } from "renderer/services/version-folder-linker.service"; +import { FolderLinkState, VersionFolderLinkerService, VersionLinkerActionListener } from "renderer/services/version-folder-linker.service"; import { useService } from "renderer/hooks/use-service.hook"; +import { BsContentTabPanel } from "../shared/bs-content-tab-panel/bs-content-tab-panel.component"; +import { BsmButton } from "../shared/bsm-button.component"; +import { MapIcon } from "../svgs/icons/map-icon.component"; +import { PlaylistIcon } from "../svgs/icons/playlist-icon.component"; +import { useObservable } from "renderer/hooks/use-observable.hook"; +import { of } from "rxjs"; type Props = { version?: BSVersion; @@ -29,7 +27,6 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) { const mapsService = useService(MapsManagerService); const mapsDownloader = useService(MapsDownloaderService); - const osDiagnostic = useService(OsDiagnosticService); const linker = useService(VersionFolderLinkerService); const [tabIndex, setTabIndex] = useState(0); @@ -37,9 +34,7 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) { const [mapSearch, setMapSearch] = useState(""); const [playlistSearch, setPlaylistSearch] = useState(""); const [mapsLinked, setMapsLinked] = useState(false); - const [linkingPending, setLinkingPending] = useState(false); - const isOnline = useObservable(osDiagnostic.isOnline$); - const color = useThemeColor("first-color"); + const mapsLinkedState = useObservable(() => version ? mapsService.$mapsFolderLinkState(version) : of(null), FolderLinkState.Unlinked, [version]); const t = useTranslation(); const mapsRef = useRef(); @@ -50,8 +45,6 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) { loadMapIsLinked(); - const sub = mapsService.$mapsLinkingPending(version).pipe(debounceTime(50)).subscribe(setLinkingPending); - const onMapsLinked: VersionLinkerActionListener = action => { if (!action.relativeFolder.includes(MapsManagerService.RELATIVE_MAPS_FOLDER)) { return; @@ -63,7 +56,6 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) { linker.onVersionFolderUnlinked(onMapsLinked); return () => { - sub.unsubscribe(); linker.removeVersionFolderLinkedListener(onMapsLinked); linker.removeVersionFolderUnlinkedListener(onMapsLinked); }; @@ -80,6 +72,10 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) { return setPlaylistSearch(() => value); }; + const handleMapsAddClick = () => { + mapsDownloader.openDownloadMapModal(version, mapsRef.current.getMaps?.()); + }; + const handleMapsLinkClick = () => { if (!mapsLinked) { return mapsService.linkVersion(version); @@ -87,56 +83,6 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) { return mapsService.unlinkVersion(version); }; - const handleMapsAddClick = () => { - mapsDownloader.openDownloadMapModal(version, mapsRef.current.getMaps?.()); - }; - - const renderTab = (props: DetailedHTMLProps, HTMLLIElement>, text: string, index: number): JSX.Element => { - const onClickLink = (index: number) => { - if (index === 0) { - handleMapsLinkClick(); - } - }; - - const onClickAdd = (index: number) => { - if (index === 0) { - handleMapsAddClick(); - } - }; - - const variants: Variants = { hover: { rotate: 22.5 }, tap: { rotate: 45 } }; - - return ( -
  • - {text} - {index === 0 && ( -
    - {isOnline && ( - { - e.stopPropagation(); - onClickAdd(index); - }} - tabIndex={-1} - > - - - - - {t("misc.add")} - - )} - {!!version && onClickLink(index)} />} -
    - )} -
  • - ); - }; - const dropDownItems = ((): DropDownItem[] => { if (tabIndex === 1) { return []; @@ -150,6 +96,15 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) { return (
    -
    - -
    + setTabIndex(index)} + tabs={[ + { + text: "misc.maps", + icon: MapIcon, + onClick: () => setTabIndex(0), + linkProps: version ? { + state: mapsLinkedState, + onClick: handleMapsLinkClick, + } : null, + }, + { + text: "misc.playlists", + icon: PlaylistIcon, + onClick: () => setTabIndex(1), + }, + ]} + > + <>
    Coming soon
    -
    -
    + +
    ); } diff --git a/src/renderer/components/maps-mangement-components/maps-row.component.tsx b/src/renderer/components/maps-mangement-components/maps-row.component.tsx index e92d4ac2..0e99fd27 100644 --- a/src/renderer/components/maps-mangement-components/maps-row.component.tsx +++ b/src/renderer/components/maps-mangement-components/maps-row.component.tsx @@ -16,13 +16,10 @@ type Props = { }; export const MapsRow = memo(({ maps, style, selectedMaps$, onMapSelect, onMapDelete }: Props) => { - const selectedMaps = useObservable( - selectedMaps$.pipe( + const selectedMaps = useObservable(() => selectedMaps$.pipe( map(selectedMaps => selectedMaps.filter(selected => maps.some(map => map.hash === selected.hash))), - distinctUntilChanged(equal) - ), - [] - ); + distinctUntilChanged(equal), + ), []); const extractMapDiffs = (map: BsmLocalMap): Map => { const res = new Map(); @@ -48,11 +45,30 @@ export const MapsRow = memo(({ maps, style, selectedMaps$, onMapSelect, onMapDel }; const renderMapItem = (map: BsmLocalMap) => { - return selected.hash === map.hash)} diffs={extractMapDiffs(map)} mapId={map.bsaverInfo?.id} ranked={map.bsaverInfo?.ranked} autorId={map.bsaverInfo?.uploader?.id} likes={map.bsaverInfo?.stats?.upvotes} createdAt={map.bsaverInfo?.createdAt} onDelete={onMapDelete} onSelected={onMapSelect} callBackParam={map} />; + return selected.hash === map.hash)} + diffs={extractMapDiffs(map)} mapId={map.bsaverInfo?.id} + ranked={map.bsaverInfo?.ranked} + autorId={map.bsaverInfo?.uploader?.id} + likes={map.bsaverInfo?.stats?.upvotes} + createdAt={map.bsaverInfo?.createdAt} + onDelete={onMapDelete} + onSelected={onMapSelect} + callBackParam={map} + />; }; return ( -
      +
        {maps?.map(renderMapItem)}
      ); diff --git a/src/renderer/components/modal/modal-types/bs-downgrade/login-to-steam-modal.component.tsx b/src/renderer/components/modal/modal-types/bs-downgrade/login-to-steam-modal.component.tsx index ef511bf6..5d4169d2 100644 --- a/src/renderer/components/modal/modal-types/bs-downgrade/login-to-steam-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/bs-downgrade/login-to-steam-modal.component.tsx @@ -21,7 +21,7 @@ export const LoginToSteamModal: ModalComponent< const [password, setPassword] = useState(""); const [stay, setStay] = useState(false); const [showPassword, setShowPassword] = useState(false); - const qrCodeUrl = useObservable(data.qrCode$); + const qrCodeUrl = useObservable(() => data.qrCode$); const t = useTranslation(); useEffect(() => { diff --git a/src/renderer/components/modal/modal-types/download-maps-modal.component.tsx b/src/renderer/components/modal/modal-types/download-maps-modal.component.tsx index a42e710f..04fa513b 100644 --- a/src/renderer/components/modal/modal-types/download-maps-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/download-maps-modal.component.tsx @@ -27,8 +27,8 @@ export const DownloadMapsModal: ModalComponent mapsDownloader.currentMapDownload$); + const mapsInQueue = useObservable(() => mapsDownloader.mapsInQueue$); const t = useTranslation(); const filterContainerRef = useRef(null); const [filter, setFilter] = useState({}); @@ -37,7 +37,7 @@ export const DownloadMapsModal: ModalComponent(BSV_SORT_ORDER.at(0)); const [ownedMapHashs, setOwnedMapHashs] = useState(ownedMaps?.map(map => map.hash) ?? []); const [loading, setLoading] = useState(false); - const isOnline = useObservable(os.isOnline$); + const isOnline = useObservable(() => os.isOnline$); const [searchParams, setSearchParams] = useState({ sortOrder, filter, diff --git a/src/renderer/components/modal/modal-types/models/download-models-modal.component.tsx b/src/renderer/components/modal/modal-types/models/download-models-modal.component.tsx index 0d0b1a76..d6982bd3 100644 --- a/src/renderer/components/modal/modal-types/models/download-models-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/models/download-models-modal.component.tsx @@ -44,10 +44,10 @@ export const DownloadModelsModal: ModalComponent modelsDownloader.currentDownload$(), null); + const downloadQueue = useObservable(() => modelsDownloader.getQueue$(), []); const [msModels, msModels$] = useBehaviorSubject([]); - const isOnline = useObservable(os.isOnline$, true); + const isOnline = useObservable(() => os.isOnline$, true); const [error, error$] = useBehaviorSubject(false); const [isLoading, isLoading$] = useBehaviorSubject(false); const [ownedModels, setOwnedModels] = useState(owned ?? []); diff --git a/src/renderer/components/modal/modal-types/share-folders-modal.component.tsx b/src/renderer/components/modal/modal-types/share-folders-modal.component.tsx index 75907e77..c04865d7 100644 --- a/src/renderer/components/modal/modal-types/share-folders-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/share-folders-modal.component.tsx @@ -1,5 +1,4 @@ import Tippy from "@tippyjs/react"; -import { Variants } from "framer-motion"; import { useEffect, useState } from "react"; import { LinkButton } from "renderer/components/maps-mangement-components/link-button.component"; import { BsmBasicSpinner } from "renderer/components/shared/bsm-basic-spinner/bsm-basic-spinner.component"; @@ -12,7 +11,7 @@ import { BSVersionManagerService } from "renderer/services/bs-version-manager.se import { ConfigurationService } from "renderer/services/configuration.service"; import { IpcService } from "renderer/services/ipc.service"; import { ModalComponent } from "renderer/services/modale.service"; -import { VersionFolderLinkerService, VersionLinkerActionType } from "renderer/services/version-folder-linker.service"; +import { FolderLinkState, VersionFolderLinkerService, VersionLinkerActionType } from "renderer/services/version-folder-linker.service"; import { BSVersion } from "shared/bs-version.interface"; export const ShareFoldersModal: ModalComponent = ({ data }) => { @@ -107,33 +106,12 @@ const FolderItem = ({ version, relativeFolder, onDelete }: FolderProps) => { const t = useTranslation(); - const name = relativeFolder.split(window.electron.path.sep).at(-1); const color = useThemeColor("first-color"); - - const variants: Variants = { - hover: { rotate: 22.5 }, - tap: { rotate: 45 }, - }; - - const pending = useObservable(linker.isPending(version, relativeFolder)); - const processing = useObservable(linker.isProcessing(version, relativeFolder)); - const linkDisabled = pending || processing; - - const [linked, setLinked] = useState(false); - - useEffect(() => { - if (pending) { - return; - } - loadFolderIsLinked(); - }, [version, relativeFolder, pending]); - - const loadFolderIsLinked = () => { - linker.isVersionFolderLinked(version, relativeFolder).toPromise().then(setLinked); - }; + const state = useObservable(() => linker.$folderLinkedState(version, relativeFolder), FolderLinkState.Unlinked, [version, relativeFolder]); + const name = relativeFolder.split(window.electron.path.sep).at(-1); const onClickLink = () => { - if (linked) { + if (state === FolderLinkState.Linked) { return linker.unlinkVersionFolder({ version, relativeFolder, @@ -150,7 +128,6 @@ const FolderItem = ({ version, relativeFolder, onDelete }: FolderProps) => { const cancelLink = () => { linker.cancelAction(version, relativeFolder); - loadFolderIsLinked(); }; return ( @@ -159,14 +136,18 @@ const FolderItem = ({ version, relativeFolder, onDelete }: FolderProps) => { {name}
      - - + + {(() => { - if (processing) { + if (state === FolderLinkState.Processing) { return ; } - if (pending) { + if (state === FolderLinkState.Pending) { return ( modalSevice.getModalToShow()); const modalData = modalSevice.getModalData(); const resolver = modalSevice.getResolver(); diff --git a/src/renderer/components/models-management/models-panel.component.tsx b/src/renderer/components/models-management/models-panel.component.tsx index 1bf18a2f..1b1f96fe 100644 --- a/src/renderer/components/models-management/models-panel.component.tsx +++ b/src/renderer/components/models-management/models-panel.component.tsx @@ -1,6 +1,5 @@ import { BSVersion } from "shared/bs-version.interface"; 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"; @@ -14,6 +13,16 @@ import { NotificationService } from "renderer/services/notification.service"; import { ConfigurationService } from "renderer/services/configuration.service"; import { useTranslation } from "renderer/hooks/use-translation.hook"; import { lt } from "semver"; +import { lastValueFrom, of, take } from "rxjs"; +import { FolderLinkState } from "renderer/services/version-folder-linker.service"; +import { BsContentTabItemProps } from "../shared/bs-content-tab-panel/bs-content-tab-item.component"; +import { BsContentTabPanel } from "../shared/bs-content-tab-panel/bs-content-tab-panel.component"; +import { ModelTypeAvatarIcon } from "../svgs/icons/model-type-avatar-icon.component"; +import { SvgIcon } from "../svgs/svg-icon.type"; +import { ModelTypeSaberIcon } from "../svgs/icons/model-type-saber-icon.component"; +import { ModelTypePlatformIcon } from "../svgs/icons/model-type-platform-icon.component"; +import { ModelTypeBloqIcon } from "../svgs/icons/model-type-bloq-icon.component"; +import { useObservable } from "renderer/hooks/use-observable.hook"; export function ModelsPanel({ version, isActive, goToMods }: { version?: BSVersion; isActive: boolean; goToMods?: () => void }) { const modelsManager = useService(ModelsManagerService); @@ -29,9 +38,15 @@ export function ModelsPanel({ version, isActive, goToMods }: { version?: BSVersi const [modelTypeTab, setModelTypeTab] = useState(MSModelType.Avatar); const [currentTabIndex, setCurrentTabIndex] = useState(0); - const [search, setSearch] = useState(""); + const modelsLinkStats = { + [MSModelType.Avatar]: useObservable(() => version ? modelsManager.$modelsLinkingState(version, MSModelType.Avatar) : of(null), FolderLinkState.Unlinked, [version]), + [MSModelType.Saber]: useObservable(() => version ? modelsManager.$modelsLinkingState(version, MSModelType.Saber) : of(null), FolderLinkState.Unlinked, [version]), + [MSModelType.Platfrom]: useObservable(() => version ? modelsManager.$modelsLinkingState(version, MSModelType.Platfrom) : of(null), FolderLinkState.Unlinked, [version]), + [MSModelType.Bloq]: useObservable(() => version ? modelsManager.$modelsLinkingState(version, MSModelType.Bloq) : of(null), FolderLinkState.Unlinked, [version]), + } + useOnUpdate(() => { if (!isActive || !goToMods) { return; @@ -93,6 +108,44 @@ export function ModelsPanel({ version, isActive, goToMods }: { version?: BSVersi { text: "models.panel.actions.drop-down.delete", onClick: deleteModels, icon: "trash" }, ]; + const getModelTabProps = (model: MSModelType): BsContentTabItemProps => { + + const onClick = async () => { + const state = await lastValueFrom(modelsManager.$modelsLinkingState(version, model).pipe(take(1))); + if (state === FolderLinkState.Pending || state === FolderLinkState.Processing) { return; } + if (state === FolderLinkState.Linked) { + modelsManager.unlinkModels(model, version); + } else { + modelsManager.linkModels(model, version); + } + } + + const getModelIcon = (model: MSModelType): SvgIcon => { + switch (model) { + case MSModelType.Avatar: + return ModelTypeAvatarIcon; + case MSModelType.Saber: + return ModelTypeSaberIcon; + case MSModelType.Platfrom: + return ModelTypePlatformIcon; + case MSModelType.Bloq: + return ModelTypeBloqIcon; + default: + return ModelTypeAvatarIcon; + } + } + + return { + text: `models.types.plural.${model}`, + icon: getModelIcon(model), + onClick: () => setModelTypeTab(model), + linkProps: version ? { + onClick, + state: modelsLinkStats[model], + } : null + } + }; + return (
      @@ -112,24 +165,23 @@ export function ModelsPanel({ version, isActive, goToMods }: { version?: BSVersi
      -
      - { - setModelTypeTab(() => tab.extra); - setCurrentTabIndex(() => index); - }} - /> - -
      + setCurrentTabIndex(index)} + tabs={[ + getModelTabProps(MSModelType.Avatar), + getModelTabProps(MSModelType.Saber), + getModelTabProps(MSModelType.Platfrom), + getModelTabProps(MSModelType.Bloq), + ]} + > + <> -
      -
      + +
      ); } diff --git a/src/renderer/components/models-management/models-tabs-navbar.component.tsx b/src/renderer/components/models-management/models-tabs-navbar.component.tsx deleted file mode 100644 index 71d59464..00000000 --- a/src/renderer/components/models-management/models-tabs-navbar.component.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { BSVersion } from "shared/bs-version.interface"; -import { MSModelType } from "../../../shared/models/models/model-saber.model"; -import { LinkButton } from "../maps-mangement-components/link-button.component"; -import { useState } from "react"; -import { useOnUpdate } from "renderer/hooks/use-on-update.hook"; -import { ModelsManagerService } from "renderer/services/models-management/models-manager.service"; -import { BsContentNavBar, BsContentNavBarTab } from "../shared/bs-content-nav-bar.component"; -import { useConstant } from "renderer/hooks/use-constant.hook"; -import { BsmIcon } from "../svgs/bsm-icon.component"; -import { MODEL_TYPES } from "../../../shared/models/models/constants"; -import { useService } from "renderer/hooks/use-service.hook"; -import { useTranslation } from "renderer/hooks/use-translation.hook"; - -type Props = { - className?: string; - version?: BSVersion; - tabIndex: number; - onTabChange: (index: number, tab: BsContentNavBarTab) => void; -}; - -export function ModelsTabsNavbar({ className, version, tabIndex, onTabChange }: Props) { - const tabs = useConstant[]>(() => { - return MODEL_TYPES.map(type => ({ - text: type, - extra: type, - })); - }); - - return } />; -} - -type TabProps = { - version?: BSVersion; - modelType: MSModelType; - active?: boolean; -} & React.ComponentProps<"li">; - -function ModelTab({ version, modelType, active, onClick }: TabProps) { - const modelsManager = useService(ModelsManagerService); - - const t = useTranslation(); - - const [modelsAreLinked, setModelsAreLinked] = useState(false); - const [linkBtnDisabled, setLinkBtnDisabled] = useState(false); - - useOnUpdate(() => { - if (!version) { - return; - } - - const sub = modelsManager.$modelsLinkingPending(version, modelType).subscribe(async pending => { - if (pending) { - return setLinkBtnDisabled(() => pending); - } - const modelsLinked = await modelsManager.isModelsLinked(version, modelType); - setModelsAreLinked(() => modelsLinked); - setLinkBtnDisabled(() => pending); - }); - - return () => { - sub.unsubscribe(); - }; - }, [version]); - - const linkModels = () => modelsManager.linkModels(modelType, version); - const unlinkModels = () => modelsManager.unlinkModels(modelType, version); - - const onClickLink = () => { - if (!version) { - return Promise.resolve(); - } - if (modelsAreLinked) { - return unlinkModels(); - } - return linkModels(); - }; - - return ( -
    • -
      - - {t(`models.types.plural.${modelType}`)} -
      -
      {!!version && }
      -
    • - ); -} diff --git a/src/renderer/components/nav-bar/bsmanager-icon.component.tsx b/src/renderer/components/nav-bar/bsmanager-icon.component.tsx index 0a0ddcda..b4e1355d 100644 --- a/src/renderer/components/nav-bar/bsmanager-icon.component.tsx +++ b/src/renderer/components/nav-bar/bsmanager-icon.component.tsx @@ -12,7 +12,7 @@ export const BsManagerIcon = memo(({ className }: { className?: string }) => { const audioPlayer = useService(AudioPlayerService); const { firstColor, secondColor } = useThemeColor(); - const playing = useObservable(audioPlayer.playing$); + const playing = useObservable(() => audioPlayer.playing$); const { bpm } = audioPlayer; diff --git a/src/renderer/components/nav-bar/nav-bar-items/shared-nav-bar-item.component.tsx b/src/renderer/components/nav-bar/nav-bar-items/shared-nav-bar-item.component.tsx index 2b24dfb5..a820cdfc 100644 --- a/src/renderer/components/nav-bar/nav-bar-items/shared-nav-bar-item.component.tsx +++ b/src/renderer/components/nav-bar/nav-bar-items/shared-nav-bar-item.component.tsx @@ -12,7 +12,7 @@ export function SharedNavBarItem() { const pageState = useService(PageStateService); const t = useTranslation(); - const route = useObservable(pageState.route$); + const route = useObservable(() => pageState.route$); const color = useThemeColor("first-color"); return ( diff --git a/src/renderer/components/nav-bar/nav-bar.component.tsx b/src/renderer/components/nav-bar/nav-bar.component.tsx index adb87168..f3fcc58b 100644 --- a/src/renderer/components/nav-bar/nav-bar.component.tsx +++ b/src/renderer/components/nav-bar/nav-bar.component.tsx @@ -19,8 +19,8 @@ export function NavBar() { const versionManager = useService(BSVersionManagerService); const versionDownloader = useService(BsDownloaderService); - const downloadingVersion = useObservable(versionDownloader.downloadingVersion$.pipe(distinctUntilChanged(equal))); - const installedVersions = useObservable(versionManager.installedVersions$); + const downloadingVersion = useObservable(() => versionDownloader.downloadingVersion$.pipe(distinctUntilChanged(equal))); + const installedVersions = useObservable(() => versionManager.installedVersions$); const color = useThemeColor("first-color"); const t = useTranslation(); diff --git a/src/renderer/components/notification/notification-overlay.component.tsx b/src/renderer/components/notification/notification-overlay.component.tsx index 4709235a..eea04f13 100644 --- a/src/renderer/components/notification/notification-overlay.component.tsx +++ b/src/renderer/components/notification/notification-overlay.component.tsx @@ -7,7 +7,7 @@ import { useService } from "renderer/hooks/use-service.hook"; export function NotificationOverlay() { const notificationService = useService(NotificationService); - const notifications = useObservable(notificationService.notifications$); + const notifications = useObservable(() => notificationService.notifications$); return (
        diff --git a/src/renderer/components/progress-bar/bsm-progress-bar.component.tsx b/src/renderer/components/progress-bar/bsm-progress-bar.component.tsx index 13eea1d6..1b005c79 100644 --- a/src/renderer/components/progress-bar/bsm-progress-bar.component.tsx +++ b/src/renderer/components/progress-bar/bsm-progress-bar.component.tsx @@ -9,9 +9,9 @@ import { useService } from "renderer/hooks/use-service.hook"; export function BsmProgressBar() { const progressBarService = useService(ProgressBarService); - const progressData = useObservable(progressBarService.progressData$); - const visible = useObservable(progressBarService.visible$); - const style = useObservable(progressBarService.style$); + const progressData = useObservable(() => progressBarService.progressData$); + const visible = useObservable(() => progressBarService.visible$); + const style = useObservable(() => progressBarService.style$); const progressLabel = (() => { if (!progressData) { diff --git a/src/renderer/components/progress-bar/text-progress-bar.component.tsx b/src/renderer/components/progress-bar/text-progress-bar.component.tsx index 3179f2a2..81964e87 100644 --- a/src/renderer/components/progress-bar/text-progress-bar.component.tsx +++ b/src/renderer/components/progress-bar/text-progress-bar.component.tsx @@ -9,7 +9,7 @@ type Props = { }; export default function TextProgressBar({ value$, className, style }: Props) { - const value = useObservable(value$); + const value = useObservable(() => value$); const prefix = typeof value === "number" ? "%" : ""; diff --git a/src/renderer/components/settings/setting-radio-array.component.tsx b/src/renderer/components/settings/setting-radio-array.component.tsx index 60eb44a1..1a7f2e60 100644 --- a/src/renderer/components/settings/setting-radio-array.component.tsx +++ b/src/renderer/components/settings/setting-radio-array.component.tsx @@ -21,7 +21,7 @@ export function SettingRadioArray({ id, items, selectedItemId, selectedItemVa return (
        {items.map(i => ( -
        onItemSelected(i)} key={i.id} className={`py-3 w-full flex cursor-pointer justify-between items-center rounded-md px-2 transition-colors duration-200 ${isSelected(i) ? "bg-light-main-color-3 dark:bg-main-color-3" : "bg-light-main-color-1 dark:bg-main-color-1"} ${i.className}`}> +
        onItemSelected(i)} key={i.id} className={`py-3 w-full flex cursor-pointer justify-between items-center rounded-md px-2 transition-colors duration-300 ${isSelected(i) ? "bg-light-main-color-3 dark:bg-main-color-3" : "bg-light-main-color-1 dark:bg-main-color-1"} ${i.className}`}>
        diff --git a/src/renderer/components/shared/bs-content-nav-bar.component.tsx b/src/renderer/components/shared/bs-content-nav-bar.component.tsx deleted file mode 100644 index e747df83..00000000 --- a/src/renderer/components/shared/bs-content-nav-bar.component.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { DetailedHTMLProps, Fragment } from "react"; -import { BsmIconType } from "../svgs/bsm-icon.component"; - -type Props = { - className?: string; - tabIndex: number; - tabs: BsContentNavBarTab[]; - renderTab: (props: DetailedHTMLProps, any>, tab?: BsContentNavBarTab, activeTab?: BsContentNavBarTab) => JSX.Element; - onTabChange?: (index: number, tab?: BsContentNavBarTab) => void; -}; - -export type BsContentNavBarTab = { - text: string; - icon?: BsmIconType; - extra?: T; -}; - -export function BsContentNavBar({ className, tabIndex, tabs, renderTab, onTabChange }: Props) { - const handleTabClick = (index: number) => { - onTabChange?.(index, tabs[index]); - }; - - return ( - - ); -} diff --git a/src/renderer/components/shared/bs-content-tab-panel/bs-content-tab-item.component.tsx b/src/renderer/components/shared/bs-content-tab-panel/bs-content-tab-item.component.tsx new file mode 100644 index 00000000..336dc3df --- /dev/null +++ b/src/renderer/components/shared/bs-content-tab-panel/bs-content-tab-item.component.tsx @@ -0,0 +1,44 @@ +import { LinkBtnProps, LinkButton } from "renderer/components/maps-mangement-components/link-button.component"; +import { SvgIcon } from "renderer/components/svgs/svg-icon.type"; +import { useTranslation } from "renderer/hooks/use-translation.hook"; + +export type BsContentTabItemProps = { + text: string; + icon: SvgIcon; + active?: boolean; + value?: T; + linkProps?: LinkBtnProps; + onClick: (value?: T) => void; +}; + +type BsContentTabItemComponent = ({text, icon, active, value, linkProps, onClick}: BsContentTabItemProps) => JSX.Element; + +export const BsContentTabItem: BsContentTabItemComponent = ({ text, icon: Icon, active, value, linkProps, onClick }) => { + + const t = useTranslation(); + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onClick(value); + } + + return ( +
      • +
        + + {t(text)} +
        + {linkProps && ( +
        + +
        + )} +
      • + ); +} \ No newline at end of file diff --git a/src/renderer/components/shared/bs-content-tab-panel/bs-content-tab-panel.component.tsx b/src/renderer/components/shared/bs-content-tab-panel/bs-content-tab-panel.component.tsx new file mode 100644 index 00000000..6cc1257e --- /dev/null +++ b/src/renderer/components/shared/bs-content-tab-panel/bs-content-tab-panel.component.tsx @@ -0,0 +1,35 @@ +import { useThemeColor } from "renderer/hooks/use-theme-color.hook"; +import { LaserSlider } from "../laser-slider.component"; +import { BsContentTabItem, BsContentTabItemProps } from "./bs-content-tab-item.component"; + +type Props = Readonly<{ + className?: string; + tabs: BsContentTabItemProps[]; + tabIndex: number; + onTabChange: (index: number, tab: BsContentTabItemProps) => void; + children: JSX.Element; +}>; + +export function BsContentTabPanel({className, tabIndex, tabs, onTabChange, children}: Props) { + + const sliderColor = useThemeColor("second-color"); + + return ( +
        + + +
        + {children} +
        +
        + ) +} diff --git a/src/renderer/components/shared/iframe-view.component.tsx b/src/renderer/components/shared/iframe-view.component.tsx index 2ca5ebd6..c378d846 100644 --- a/src/renderer/components/shared/iframe-view.component.tsx +++ b/src/renderer/components/shared/iframe-view.component.tsx @@ -8,7 +8,7 @@ import { LinkOpenerService } from "renderer/services/link-opener.service"; export function BsmIframeView() { const linkService = useService(LinkOpenerService); - const iframeUrl = useObservable(linkService.iframeLink$); + const iframeUrl = useObservable(() => linkService.iframeLink$); const ref = useRef(null); useClickOutside(ref, () => linkService.closeIframe()); diff --git a/src/renderer/components/shared/laser-slider.component.tsx b/src/renderer/components/shared/laser-slider.component.tsx new file mode 100644 index 00000000..ce35107a --- /dev/null +++ b/src/renderer/components/shared/laser-slider.component.tsx @@ -0,0 +1,35 @@ +import { CSSProperties } from "react"; + +type Props = Readonly<{ + mode: "horizontal" | "vertical"; + color: string; + className?: string; + nbSteps: number; + step: number; +}> + +export function LaserSlider({mode, color, className, nbSteps, step}: Props) { + + const sliderStyle = ((): CSSProperties => { + if(mode === "vertical"){ + return { + transform: `translate(0, ${step * 100}%)`, + height: `calc(100% / ${nbSteps})`, + width: "100%" + } + } + + return { + transform: `translate(${step * 100}%, 0)`, + width: `calc(100% / ${nbSteps})`, + height: "100%" + } + })(); + + return ( +
        + + +
        + ) +} diff --git a/src/renderer/components/shared/tab-nav-bar.component.tsx b/src/renderer/components/shared/tab-nav-bar.component.tsx index 0484ec58..960ab97e 100644 --- a/src/renderer/components/shared/tab-nav-bar.component.tsx +++ b/src/renderer/components/shared/tab-nav-bar.component.tsx @@ -1,6 +1,7 @@ import { DetailedHTMLProps, Fragment } from "react"; import { useThemeColor } from "renderer/hooks/use-theme-color.hook"; import { useTranslation } from "renderer/hooks/use-translation.hook"; +import { LaserSlider } from "./laser-slider.component"; type Props = { tabIndex: number; @@ -22,10 +23,7 @@ export function TabNavBar(props: Props) { return (
        - + diff --git a/src/renderer/services/maps-manager.service.ts b/src/renderer/services/maps-manager.service.ts index aacf9cb6..4c227074 100644 --- a/src/renderer/services/maps-manager.service.ts +++ b/src/renderer/services/maps-manager.service.ts @@ -13,7 +13,7 @@ import { ConfigurationService } from "./configuration.service"; import { ArchiveProgress } from "shared/models/archive.interface"; import { map, last, catchError } from "rxjs/operators"; import { ProgressionInterface } from "shared/models/progress-bar"; -import { VersionFolderLinkerService, VersionLinkerActionType } from "./version-folder-linker.service"; +import { FolderLinkState, VersionFolderLinkerService, VersionLinkerActionType } from "./version-folder-linker.service"; export class MapsManagerService { private static instance: MapsManagerService; @@ -176,6 +176,10 @@ export class MapsManagerService { } public $mapsLinkingPending(version: BSVersion): Observable { - return this.linker.$isVersionFolderPending(version, MapsManagerService.RELATIVE_MAPS_FOLDER); + return this.linker.$isPending(version, MapsManagerService.RELATIVE_MAPS_FOLDER); + } + + public $mapsFolderLinkState(version: BSVersion): Observable { + return this.linker.$folderLinkedState(version, MapsManagerService.RELATIVE_MAPS_FOLDER); } } diff --git a/src/renderer/services/models-management/models-manager.service.ts b/src/renderer/services/models-management/models-manager.service.ts index 3dd5f111..0c1c230e 100644 --- a/src/renderer/services/models-management/models-manager.service.ts +++ b/src/renderer/services/models-management/models-manager.service.ts @@ -1,6 +1,6 @@ import { MSModelType } from "shared/models/models/model-saber.model"; import { IpcService } from "../ipc.service"; -import { VersionFolderLinkerService, VersionLinkerActionListener, VersionLinkerActionType } from "../version-folder-linker.service"; +import { FolderLinkState, VersionFolderLinkerService, VersionLinkerActionListener, VersionLinkerActionType } from "../version-folder-linker.service"; import { MODEL_TYPE_FOLDERS } from "shared/models/models/constants"; import { Observable, lastValueFrom, map } from "rxjs"; import { BSVersion } from "shared/bs-version.interface"; @@ -49,7 +49,11 @@ export class ModelsManagerService { } public $modelsLinkingPending(version: BSVersion, type: MSModelType): Observable { - return this.versionFolderLinked.$isVersionFolderPending(version, MODEL_TYPE_FOLDERS[type]); + return this.versionFolderLinked.$isPending(version, MODEL_TYPE_FOLDERS[type]); + } + + public $modelsLinkingState(version: BSVersion, type: MSModelType): Observable { + return this.versionFolderLinked.$folderLinkedState(version, MODEL_TYPE_FOLDERS[type]); } public onModelsFolderLinked(callback: VersionLinkerActionListener): void { diff --git a/src/renderer/services/version-folder-linker.service.ts b/src/renderer/services/version-folder-linker.service.ts index 5b7c6b33..936de9d6 100644 --- a/src/renderer/services/version-folder-linker.service.ts +++ b/src/renderer/services/version-folder-linker.service.ts @@ -1,6 +1,6 @@ import { LinkOptions, UnlinkOptions } from "main/services/folder-linker.service"; import { map, distinctUntilChanged, filter, mergeMap, shareReplay } from "rxjs/operators"; -import { BehaviorSubject, Observable } from "rxjs"; +import { BehaviorSubject, Observable, of } from "rxjs"; import { BSVersion } from "shared/bs-version.interface"; import { IpcService } from "./ipc.service"; import { ProgressBarService } from "./progress-bar.service"; @@ -62,6 +62,13 @@ export class VersionFolderLinkerService { return this.ipcService.sendV2("link-version-folder-action", { args: action }); } + private get currentAction$(): Observable { + return this._queue$.pipe( + map(actions => actions.at(0)), + distinctUntilChanged() + ); + } + public linkVersionFolder(action: VersionLinkFolderAction): Promise { const promise = new Promise(resolve => { const callBack: VersionLinkerActionListener = (performedAction, linked) => { @@ -122,12 +129,33 @@ export class VersionFolderLinkerService { return this.ipcService.sendV2("is-version-folder-linked", { args: { version, relativeFolder } }); } - public isPending(version: BSVersion, relativeFolder: string): Observable { - return this.queue$.pipe(map(queue => queue.length > 0 && queue.some(action => action.version === version && action.relativeFolder === relativeFolder), distinctUntilChanged())); + public $folderLinkedState(version: BSVersion, relativeFolder: string): Observable { + return this._queue$.pipe( + mergeMap(queue => { + const currentAction = queue.at(0); + if(currentAction && equal(currentAction.version, version) && currentAction.relativeFolder === relativeFolder) { + return of(FolderLinkState.Processing) + } + + if(queue.some(action => equal(action.version, version) && action.relativeFolder === relativeFolder)) { + return of(FolderLinkState.Pending); + } + + return this.isVersionFolderLinked(version, relativeFolder).pipe( + map(linked => linked ? FolderLinkState.Linked : FolderLinkState.Unlinked) + ); + }), + distinctUntilChanged(), + shareReplay(1) + ); } - public isProcessing(version: BSVersion, relativeFolder: string): Observable { - return this.currentAction$.pipe(map(action => action && action.version === version && action.relativeFolder === relativeFolder)); + public $isPending(version: BSVersion, relativeFolder: string): Observable { + return this.$folderLinkedState(version, relativeFolder).pipe(map(state => state === FolderLinkState.Pending)); + } + + public $isProcessing(version: BSVersion, relativeFolder: string): Observable { + return this.$folderLinkedState(version, relativeFolder).pipe(map(state => state === FolderLinkState.Processing)); } public getLinkedFolders(version: BSVersion, options?: { relative?: boolean }): Observable { @@ -137,27 +165,6 @@ export class VersionFolderLinkerService { public relinkAllVersionsFolders(): Observable { return this.ipcService.sendV2("relink-all-versions-folders"); } - - public $isVersionFolderPending(version: BSVersion, relativeFolder: string): Observable { - return this.queue$.pipe( - mergeMap(async queue => { - return queue.some(q => q.relativeFolder.includes(relativeFolder) && equal(q.version, version)); - }), - distinctUntilChanged(), - shareReplay(1) - ); - } - - public get currentAction$(): Observable { - return this._queue$.pipe( - map(actions => actions.at(0)), - distinctUntilChanged() - ); - } - - public get queue$(): Observable { - return this._queue$.asObservable(); - } } export const enum VersionLinkerActionType { @@ -182,3 +189,10 @@ export interface VersionUnlinkFolderAction extends VersionLinkerAction { } export type VersionLinkerActionListener = (action: VersionLinkerAction, linked: boolean) => void; + +export enum FolderLinkState { + Linked, + Unlinked, + Pending, + Processing +} diff --git a/src/renderer/windows/OneClick/OneClickDownloadPlaylist.tsx b/src/renderer/windows/OneClick/OneClickDownloadPlaylist.tsx index 8dab7d1b..a21bf590 100644 --- a/src/renderer/windows/OneClick/OneClickDownloadPlaylist.tsx +++ b/src/renderer/windows/OneClick/OneClickDownloadPlaylist.tsx @@ -24,8 +24,8 @@ export default function OneClickDownloadPlaylist() { const mapsContainer = useRef(null); const playlistUrl = useConstant(() => new URLSearchParams(window.location.search).get("playlistUrl")); const download$ = useConstant(() => playlistDownloader.oneClickInstallPlaylist(playlistUrl)); - const playlistInfos = useObservable(download$.pipe(filter(progress => !!progress.data?.playlistInfos), map(progress => progress.data.playlistInfos), take(1))); - const downloadedMaps = useObservable(download$.pipe(filter(progress => !!progress.data?.downloadedMaps), map(progress => progress.data.downloadedMaps))); + const playlistInfos = useObservable(() => download$.pipe(filter(progress => !!progress.data?.playlistInfos), map(progress => progress.data.playlistInfos), take(1))); + const downloadedMaps = useObservable(() => download$.pipe(filter(progress => !!progress.data?.downloadedMaps), map(progress => progress.data.downloadedMaps))); useEffect(() => { diff --git a/src/renderer/windows/ShortcutLaunch.tsx b/src/renderer/windows/ShortcutLaunch.tsx index ca2f2a87..1049676a 100644 --- a/src/renderer/windows/ShortcutLaunch.tsx +++ b/src/renderer/windows/ShortcutLaunch.tsx @@ -26,7 +26,7 @@ export default function ShortcutLaunch() { const t = useTranslation(); const color = useThemeColor("second-color"); - const launchOptions = useObservable(ipc.sendV2("shortcut-launch-options").pipe(take(1)), null) + const launchOptions = useObservable(() => ipc.sendV2("shortcut-launch-options").pipe(take(1)), null) const [rotation, setRotation] = useState(0); const [status, setStatus] = useState();