unify version downloads
This commit is contained in:
parent
5cac13a54c
commit
1c3041679e
3 changed files with 444 additions and 234 deletions
|
|
@ -325,8 +325,11 @@ export type VersionType =
|
|||
*/
|
||||
"old_beta"
|
||||
|
||||
const ARGS_MAP = { 'oneclient':'{"downloadPackageFromBundle":["package","cluster_id","bundle_name","skip_compatibility"],"getVersions":[],"isBundleSyncing":[],"getClustersGroupedByMajor":[],"extractBundleOverrides":["bundle_path","cluster_id"],"updateBundlePackages":["cluster_id"],"getBundlesFor":["cluster_id"],"cacheArt":["path"],"copyClusterContent":["source_id","target_id"],"refreshArt":["path"],"installUpdate":[],"checkForUpdate":[]}', 'core':'{"getMultiplePackages":["provider","slugs"],"getRunningProcessesByClusterId":["cluster_id"],"getGameVersions":[],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"downloadExternalPackage":["package","cluster_id","force","skip_compatibility"],"syncCluster":["cluster_id"],"createSettingsProfile":["name"],"getUsers":[],"updateClusterById":["id","request"],"getProcessLogTail":["id","max_lines"],"killProcess":["pid"],"getDefaultUser":["fallback"],"installModpack":["modpack","cluster_id"],"readSettings":[],"changeCape":["access_token","cape_uuid"],"refreshAccounts":[],"fetchMinecraftProfile":["uuid"],"createCluster":["options"],"getGlobalProfile":[],"getPackageBody":["provider","body"],"getRunningProcesses":[],"getProfileOrDefault":["name"],"removeCape":["access_token"],"togglePackage":["cluster_id","package_hash"],"getLogByName":["id","name"],"getLogs":["id"],"removePackage":["cluster_id","package_hash"],"setDefaultUser":["uuid"],"getUsersFromAuthor":["provider","author"],"getLinkedPackages":["cluster_id"],"setClusterStage":["id","stage"],"fetchLoggedInProfile":["access_token"],"setDiscordRPCMessage":["message"],"getUser":["uuid"],"removeUser":["uuid"],"getScreenshots":["id"],"getClusters":[],"isClusterRunning":["cluster_id"],"getClusterById":["id"],"getLoadersForVersion":["mc_version"],"openMsaLogin":[],"launchCluster":["id","uuid","search_for_java"],"getWorlds":["id"],"writeSettings":["setting"],"searchPackages":["provider","query"],"getPackage":["provider","slug"],"refreshAccount":["uuid"],"convertUsernameUUID":["username_uuid"],"open":["input"],"uploadSkinBytes":["access_token","skin_data","image_format","skin_variant"],"updateClusterProfile":["name","profile"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"changeSkin":["access_token","skin_url","skin_variant"],"removeCluster":["id"]}', 'debug':'{"getPackageVersion":[],"getArch":[],"getType":[],"hasDevSettings":[],"getOsVersion":[],"getOsDistro":[],"getFullDebugInfoParsed":[],"getFamily":[],"getLocale":[],"getPlatform":[],"getGitCommitHash":[],"getFullDebugInfo":[],"isInDev":[],"openDevTools":[],"getFullDebugInfoParsedString":[],"getBuildTimestamp":[]}', 'events':'{"process":["event"],"ingress":["event"],"message":["event"]}', 'folders':'{"fromCluster":["folder_name"],"openCluster":["folder_name"]}' }
|
||||
export type Router = { 'debug': { openDevTools: () => Promise<void>,
|
||||
const ARGS_MAP = { 'events':'{"ingress":["event"],"process":["event"],"message":["event"]}', 'folders':'{"fromCluster":["folder_name"],"openCluster":["folder_name"]}', 'oneclient':'{"refreshArt":["path"],"copyClusterContent":["source_id","target_id"],"getBundlesFor":["cluster_id"],"downloadPackageFromBundle":["package","cluster_id","bundle_name","skip_compatibility"],"updateBundlePackages":["cluster_id"],"extractBundleOverrides":["bundle_path","cluster_id"],"getClustersGroupedByMajor":[],"getVersions":[],"checkForUpdate":[],"installUpdate":[],"isBundleSyncing":[],"cacheArt":["path"]}', 'core':'{"getClusterById":["id"],"getProfileOrDefault":["name"],"refreshAccounts":[],"killProcess":["pid"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"getClusters":[],"getLogByName":["id","name"],"launchCluster":["id","uuid","search_for_java"],"getGameVersions":[],"getUsers":[],"getUsersFromAuthor":["provider","author"],"fetchLoggedInProfile":["access_token"],"isClusterRunning":["cluster_id"],"getProcessLogTail":["id","max_lines"],"writeSettings":["setting"],"getMultiplePackages":["provider","slugs"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"removeCape":["access_token"],"convertUsernameUUID":["username_uuid"],"open":["input"],"searchPackages":["provider","query"],"setDiscordRPCMessage":["message"],"getScreenshots":["id"],"downloadExternalPackage":["package","cluster_id","force","skip_compatibility"],"readSettings":[],"refreshAccount":["uuid"],"createCluster":["options"],"syncCluster":["cluster_id"],"togglePackage":["cluster_id","package_hash"],"updateClusterById":["id","request"],"getRunningProcesses":[],"getGlobalProfile":[],"getDefaultUser":["fallback"],"getLinkedPackages":["cluster_id"],"installModpack":["modpack","cluster_id"],"getWorlds":["id"],"getRunningProcessesByClusterId":["cluster_id"],"uploadSkinBytes":["access_token","skin_data","image_format","skin_variant"],"removeCluster":["id"],"getLoadersForVersion":["mc_version"],"removeUser":["uuid"],"removePackage":["cluster_id","package_hash"],"fetchMinecraftProfile":["uuid"],"setClusterStage":["id","stage"],"getUser":["uuid"],"updateClusterProfile":["name","profile"],"openMsaLogin":[],"getPackage":["provider","slug"],"getLogs":["id"],"createSettingsProfile":["name"],"getPackageBody":["provider","body"],"setDefaultUser":["uuid"],"changeSkin":["access_token","skin_url","skin_variant"],"changeCape":["access_token","cape_uuid"]}', 'debug':'{"getFullDebugInfo":[],"getLocale":[],"getBuildTimestamp":[],"getType":[],"getPlatform":[],"getFamily":[],"hasDevSettings":[],"getGitCommitHash":[],"getPackageVersion":[],"isInDev":[],"getOsVersion":[],"getFullDebugInfoParsedString":[],"getArch":[],"openDevTools":[],"getFullDebugInfoParsed":[],"getOsDistro":[]}' }
|
||||
export type Router = { 'events': { ingress: (event: IngressPayload) => Promise<void>,
|
||||
message: (event: MessagePayload) => Promise<void>,
|
||||
process: (event: ProcessPayload) => Promise<void> },
|
||||
'debug': { openDevTools: () => Promise<void>,
|
||||
isInDev: () => Promise<boolean>,
|
||||
getArch: () => Promise<string>,
|
||||
getFamily: () => Promise<string>,
|
||||
|
|
@ -342,6 +345,18 @@ getFullDebugInfo: () => Promise<DebugInfoData>,
|
|||
getFullDebugInfoParsed: () => Promise<DebugInfoParsedLine[]>,
|
||||
getFullDebugInfoParsedString: () => Promise<string>,
|
||||
hasDevSettings: () => Promise<boolean> },
|
||||
'oneclient': { getClustersGroupedByMajor: () => Promise<Partial<{ [key in number]: ClusterModel[] }>>,
|
||||
getBundlesFor: (clusterId: number) => Promise<ModpackArchive[]>,
|
||||
getVersions: () => Promise<OnlineClusterManifest>,
|
||||
extractBundleOverrides: (bundlePath: string, clusterId: number) => Promise<null>,
|
||||
checkForUpdate: () => Promise<Update | null>,
|
||||
installUpdate: () => Promise<null>,
|
||||
downloadPackageFromBundle: (package: ModpackFileKind, clusterId: number, bundleName: string, skipCompatibility: boolean | null) => Promise<PackageModel>,
|
||||
updateBundlePackages: (clusterId: number) => Promise<ApplyBundleUpdatesResult>,
|
||||
isBundleSyncing: () => Promise<boolean>,
|
||||
cacheArt: (path: string) => Promise<string>,
|
||||
refreshArt: (path: string) => Promise<null>,
|
||||
copyClusterContent: (sourceId: number, targetId: number) => Promise<CopyClusterResult> },
|
||||
'core': { getClusters: () => Promise<ClusterModel[]>,
|
||||
getClusterById: (id: number) => Promise<ClusterModel | null>,
|
||||
removeCluster: (id: number) => Promise<null>,
|
||||
|
|
@ -397,22 +412,7 @@ convertUsernameUUID: (usernameUuid: string) => Promise<MowojangProfile>,
|
|||
setDiscordRPCMessage: (message: string) => Promise<null>,
|
||||
open: (input: string) => Promise<null> },
|
||||
'folders': { fromCluster: (folderName: string) => Promise<string>,
|
||||
openCluster: (folderName: string) => Promise<null> },
|
||||
'events': { ingress: (event: IngressPayload) => Promise<void>,
|
||||
message: (event: MessagePayload) => Promise<void>,
|
||||
process: (event: ProcessPayload) => Promise<void> },
|
||||
'oneclient': { getClustersGroupedByMajor: () => Promise<Partial<{ [key in number]: ClusterModel[] }>>,
|
||||
getBundlesFor: (clusterId: number) => Promise<ModpackArchive[]>,
|
||||
getVersions: () => Promise<OnlineClusterManifest>,
|
||||
extractBundleOverrides: (bundlePath: string, clusterId: number) => Promise<null>,
|
||||
checkForUpdate: () => Promise<Update | null>,
|
||||
installUpdate: () => Promise<null>,
|
||||
downloadPackageFromBundle: (package: ModpackFileKind, clusterId: number, bundleName: string, skipCompatibility: boolean | null) => Promise<PackageModel>,
|
||||
updateBundlePackages: (clusterId: number) => Promise<ApplyBundleUpdatesResult>,
|
||||
isBundleSyncing: () => Promise<boolean>,
|
||||
cacheArt: (path: string) => Promise<string>,
|
||||
refreshArt: (path: string) => Promise<null>,
|
||||
copyClusterContent: (sourceId: number, targetId: number) => Promise<CopyClusterResult> } };
|
||||
openCluster: (folderName: string) => Promise<null> } };
|
||||
|
||||
|
||||
export type { InferCommandOutput }
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ import { OnboardingNavigation } from '@/routes/onboarding/route';
|
|||
import { formatMcVersion } from '@/utils/versionMap';
|
||||
import { useCommandSuspense } from '@onelauncher/common';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
|
||||
import { useState } from 'react';
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from '@untitled-theme/icons-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Button as AriaButton } from 'react-aria-components';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
|
|
@ -15,101 +15,149 @@ export interface StrippedCluster {
|
|||
mc_loader: GameLoader;
|
||||
}
|
||||
|
||||
interface VersionSlide {
|
||||
cluster: OnlineCluster;
|
||||
entry: OnlineClusterEntry;
|
||||
fullVersionName: string;
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/onboarding/preferences/version')({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { data: versions } = useCommandSuspense(['getVersions'], () => bindings.oneclient.getVersions());
|
||||
// const { data: clusters } = useCommandSuspense(['getClusters'], () => bindings.core.getClusters());
|
||||
|
||||
const [selectedClusters, setSelectedClusters] = useState<Array<StrippedCluster>>([]);
|
||||
const slides = useMemo<Array<VersionSlide>>(() => {
|
||||
return versions.clusters.flatMap(cluster =>
|
||||
cluster.entries.map(entry => ({
|
||||
cluster,
|
||||
entry,
|
||||
fullVersionName: formatMcVersion(cluster.major_version, entry.minor_version),
|
||||
})));
|
||||
}, [versions.clusters]);
|
||||
|
||||
useEffect(() => {
|
||||
const allClusters: Array<StrippedCluster> = slides.map(s => ({
|
||||
mc_version: s.fullVersionName,
|
||||
mc_loader: s.entry.loader,
|
||||
}));
|
||||
localStorage.setItem('selectedClusters', JSON.stringify(allClusters));
|
||||
}, [slides]);
|
||||
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
const goLeft = () => setCurrentIndex(i => Math.max(0, i - 1));
|
||||
const goRight = () => setCurrentIndex(i => Math.min(slides.length - 1, i + 1));
|
||||
|
||||
if (slides.length === 0)
|
||||
return <OnboardingNavigation />;
|
||||
|
||||
const current = slides[currentIndex];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="min-h-screen px-7">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<OverlayScrollbarsComponent>
|
||||
<div className="h-164">
|
||||
<h1 className="text-4xl font-semibold mb-2">Starting Versions</h1>
|
||||
<p className="text-slate-400 text-lg mb-2">
|
||||
Something something in corporate style fashion about picking your preferred gamemodes and versions and
|
||||
optionally loader so that oneclient can pick something for them
|
||||
</p>
|
||||
<div className="h-[calc(100vh-12rem)] flex flex-col">
|
||||
<h1 className="text-4xl font-semibold mb-2">Available Versions</h1>
|
||||
<p className="text-slate-400 text-lg mb-4">
|
||||
These are the versions OneClient will set up for you. Browse through to see what's available.
|
||||
</p>
|
||||
|
||||
<div className="bg-page-elevated p-4 rounded-xl grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 gap-6">
|
||||
{versions.clusters.map((cluster) => {
|
||||
return cluster.entries.map(entry => (
|
||||
<VersionCard
|
||||
cluster={cluster}
|
||||
fullVersionName={formatMcVersion(cluster.major_version, entry.minor_version)}
|
||||
key={`${formatMcVersion(cluster.major_version, entry.minor_version)}-${entry.loader}`}
|
||||
setSelectedClusters={setSelectedClusters}
|
||||
version={entry}
|
||||
/>
|
||||
));
|
||||
})}
|
||||
<div className="flex-1 min-h-0 flex flex-col items-center gap-4">
|
||||
<div className="relative w-full max-w-4xl flex-1 min-h-0">
|
||||
<CarouselSlide
|
||||
className="h-full"
|
||||
cluster={current.cluster}
|
||||
fullVersionName={current.fullVersionName}
|
||||
version={current.entry}
|
||||
/>
|
||||
|
||||
<AriaButton
|
||||
className={twMerge(
|
||||
'absolute left-3 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white rounded-full p-2 transition-colors',
|
||||
currentIndex === 0 && 'opacity-30 cursor-default',
|
||||
)}
|
||||
isDisabled={currentIndex === 0}
|
||||
onPress={goLeft}
|
||||
>
|
||||
<ArrowLeftIcon className="w-5 h-5" />
|
||||
</AriaButton>
|
||||
|
||||
<AriaButton
|
||||
className={twMerge(
|
||||
'absolute right-3 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white rounded-full p-2 transition-colors',
|
||||
currentIndex === slides.length - 1 && 'opacity-30 cursor-default',
|
||||
)}
|
||||
isDisabled={currentIndex === slides.length - 1}
|
||||
onPress={goRight}
|
||||
>
|
||||
<ArrowRightIcon className="w-5 h-5" />
|
||||
</AriaButton>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{slides.map((slide, i) => (
|
||||
<AriaButton
|
||||
className={twMerge(
|
||||
'w-2.5 h-2.5 rounded-full transition-all',
|
||||
i === currentIndex ? 'bg-brand scale-125' : 'bg-white/30 hover:bg-white/50',
|
||||
)}
|
||||
key={`${slide.fullVersionName}-${slide.entry.loader}`}
|
||||
onPress={() => setCurrentIndex(i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<span className="text-sm text-slate-400">
|
||||
{currentIndex + 1}
|
||||
{' '}
|
||||
/
|
||||
{' '}
|
||||
{slides.length}
|
||||
</span>
|
||||
</div>
|
||||
</OverlayScrollbarsComponent>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OnboardingNavigation disableNext={selectedClusters.length === 0} />
|
||||
<OnboardingNavigation />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function VersionCard({ cluster, version, fullVersionName, setSelectedClusters }: { cluster: OnlineCluster; version: OnlineClusterEntry; fullVersionName: string; setSelectedClusters: React.Dispatch<React.SetStateAction<Array<StrippedCluster>>> }) {
|
||||
const [isSelected, setSelected] = useState<boolean>(false);
|
||||
const toggle = () => {
|
||||
setSelected(prev => !prev);
|
||||
setSelectedClusters((prev) => {
|
||||
let updatedClusters: Array<StrippedCluster> = [];
|
||||
const exists = prev.some(strippedCluster => strippedCluster.mc_version === fullVersionName && strippedCluster.mc_loader === version.loader);
|
||||
if (exists)
|
||||
updatedClusters = prev.filter(strippedCluster => !(strippedCluster.mc_version === fullVersionName && strippedCluster.mc_loader === version.loader));
|
||||
else
|
||||
updatedClusters = [...prev, { mc_version: fullVersionName, mc_loader: version.loader }];
|
||||
|
||||
localStorage.setItem('selectedClusters', JSON.stringify(updatedClusters));
|
||||
return updatedClusters;
|
||||
});
|
||||
};
|
||||
|
||||
function CarouselSlide({ cluster, version, fullVersionName, className }: { cluster: OnlineCluster; version: OnlineClusterEntry; fullVersionName: string; className?: string }) {
|
||||
const artPath = version.art ?? cluster.art;
|
||||
const artSrc = useCachedImage(artPath);
|
||||
|
||||
return (
|
||||
<AriaButton className={twMerge('group overflow-hidden cursor-pointer w-full rounded-xl transition-[outline] outline-2 hover:outline-brand', isSelected ? 'outline-brand' : 'outline-ghost-overlay')} onPress={toggle}>
|
||||
<div className="relative w-full">
|
||||
<div className={twMerge('overflow-hidden rounded-xl', className)}>
|
||||
<div className="relative w-full h-full">
|
||||
{artSrc
|
||||
? (
|
||||
<img
|
||||
alt={`Minecraft ${fullVersionName} landscape`}
|
||||
className={twMerge('w-full rounded-xl h-32 object-cover transition-[filter] group-hover:brightness-100 group-hover:grayscale-0', isSelected ? 'brightness-100 grayscale-0' : 'brightness-70 grayscale-25')}
|
||||
className="w-full rounded-xl h-full object-cover"
|
||||
src={artSrc}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<div className={twMerge('w-full rounded-xl h-32 bg-page-elevated transition-[filter] group-hover:brightness-100 group-hover:grayscale-0', isSelected ? 'brightness-100 grayscale-0' : 'brightness-70 grayscale-25')} />
|
||||
<div className="w-full rounded-xl h-full bg-page-elevated" />
|
||||
)}
|
||||
|
||||
<div className="absolute top-3 left-3 flex flex-wrap gap-1">
|
||||
{
|
||||
version.tags.map(tag => (
|
||||
<div className="bg-[#D0D7F3] rounded-xl text-brand text-sm px-2 py-1" key={tag}>
|
||||
{tag}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
{version.tags.map(tag => (
|
||||
<div className="bg-[#D0D7F3] rounded-xl text-brand text-sm px-2 py-1" key={tag}>
|
||||
{tag}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-3 left-3">
|
||||
<span className="text-white font-bold px-3 py-1 text-xl">{fullVersionName}</span>
|
||||
<span className="text-white font-bold px-4 py-2 text-4xl drop-shadow-lg">{fullVersionName}</span>
|
||||
</div>
|
||||
</div>
|
||||
</AriaButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import type { ClusterModel, ModpackArchive, ModpackFile } from '@/bindings.gen';
|
||||
import type { ClusterModel, ModpackArchive, ModpackFile, OnlineClusterManifest } from '@/bindings.gen';
|
||||
import type { DownloadModsRef, ModCardContextApi, ModWithBundle, onClickOnMod } from '@/components';
|
||||
import type { StrippedCluster } from '@/routes/onboarding/preferences/version';
|
||||
import { BundleModListModal, DownloadMods, ModCardContext, Overlay } from '@/components';
|
||||
import { DownloadMods, ModCardContext, ModList, Overlay } from '@/components';
|
||||
import { useCachedImage } from '@/hooks/useCachedImage';
|
||||
import { bindings } from '@/main';
|
||||
import { OnboardingNavigation } from '@/routes/onboarding/route';
|
||||
|
|
@ -11,7 +10,7 @@ import { Button } from '@onelauncher/common/components';
|
|||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { DotsVerticalIcon } from '@untitled-theme/icons-react';
|
||||
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
|
||||
import { useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
|
||||
import { Button as AriaButton } from 'react-aria-components';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
|
|
@ -19,96 +18,172 @@ export const Route = createFileRoute('/onboarding/preferences/versionCategory')(
|
|||
component: RouteComponent,
|
||||
});
|
||||
|
||||
export interface BundleData {
|
||||
bundles: Array<ModpackArchive>;
|
||||
art: string;
|
||||
modsInfo: Array<ModpackFile>;
|
||||
clusterId: number;
|
||||
function extractDisplayName(manifestName: string): string {
|
||||
return manifestName.match(/\[(.*?)\]/)?.[1] ?? manifestName;
|
||||
}
|
||||
|
||||
function HandleCLuster(cluster: ClusterModel): Array<ModpackArchive> {
|
||||
function resolveArt(cluster: ClusterModel, versions: OnlineClusterManifest): string {
|
||||
const onlineCluster = getOnlineClusterForVersion(cluster.mc_version, versions);
|
||||
const parsed = parseMcVersion(cluster.mc_version);
|
||||
const entry = parsed?.minor !== undefined
|
||||
? onlineCluster?.entries.find(e => e.minor_version === parsed.minor)
|
||||
: undefined;
|
||||
return entry?.art ?? onlineCluster?.art ?? '/versions/art/Horse_Update.jpg';
|
||||
}
|
||||
|
||||
interface ClusterWithBundles {
|
||||
cluster: ClusterModel;
|
||||
bundles: Array<ModpackArchive>;
|
||||
}
|
||||
|
||||
interface UnifiedBundle {
|
||||
displayName: string;
|
||||
art: string;
|
||||
modCount: number;
|
||||
clustersWithBundle: Array<ClusterWithBundles>;
|
||||
}
|
||||
|
||||
const BUNDLE_CARD_CONTEXT: ModCardContextApi = { useVerticalGridLayout: true };
|
||||
|
||||
function HandleCluster(cluster: ClusterModel): Array<ModpackArchive> {
|
||||
const { data: bundle } = useCommandSuspense(['getBundlesFor', cluster.id], () => bindings.oneclient.getBundlesFor(cluster.id));
|
||||
return bundle;
|
||||
}
|
||||
|
||||
function RouteComponent() {
|
||||
const selectedClusters: Array<StrippedCluster> = JSON.parse(localStorage.getItem('selectedClusters') ?? '[]');
|
||||
|
||||
const { data: versions } = useCommandSuspense(['getVersions'], () => bindings.oneclient.getVersions());
|
||||
const { data: clusters } = useCommandSuspense(['getClusters'], () => bindings.core.getClusters());
|
||||
const bundleQueries = clusters.map(cluster => HandleCLuster(cluster));
|
||||
const bundlesData: Record<string, BundleData> = {};
|
||||
clusters.forEach((cluster, i) => {
|
||||
const selected = selectedClusters.some(selectedCluster => selectedCluster.mc_version === cluster.mc_version && selectedCluster.mc_loader === cluster.mc_loader);
|
||||
if (!selected)
|
||||
return;
|
||||
const onlineCluster = getOnlineClusterForVersion(cluster.mc_version, versions);
|
||||
const parsedVersion = parseMcVersion(cluster.mc_version);
|
||||
const onlineEntry = parsedVersion?.minor !== undefined
|
||||
? onlineCluster?.entries.find(e => e.minor_version === parsedVersion.minor)
|
||||
: undefined;
|
||||
bundlesData[cluster.name] = {
|
||||
bundles: bundleQueries[i],
|
||||
art: onlineEntry?.art ?? onlineCluster?.art ?? '/versions/art/Horse_Update.jpg',
|
||||
modsInfo: [],
|
||||
clusterId: cluster.id,
|
||||
};
|
||||
});
|
||||
const bundleQueries = clusters.map(cluster => HandleCluster(cluster));
|
||||
|
||||
const [modsPerCluster, setModsPerCluster] = useState<Record<string, Array<ModWithBundle>>>(
|
||||
clusters.reduce((acc, cluster, i) => {
|
||||
const isSelectedCluster = selectedClusters.some(selectedCluster => (
|
||||
selectedCluster.mc_version === cluster.mc_version
|
||||
&& selectedCluster.mc_loader === cluster.mc_loader
|
||||
));
|
||||
if (!isSelectedCluster) {
|
||||
acc[cluster.id] = [];
|
||||
return acc;
|
||||
// Use ALL local clusters — no filter against selectedClusters.
|
||||
const allClusters = useMemo<Array<ClusterWithBundles>>(() => {
|
||||
return clusters.map((cluster, i) => ({ cluster, bundles: bundleQueries[i] }));
|
||||
}, [clusters, bundleQueries]);
|
||||
|
||||
const bundlesPerCluster = useMemo(() => {
|
||||
const result: Record<number, Array<ModpackArchive>> = {};
|
||||
for (const { cluster, bundles } of allClusters)
|
||||
result[cluster.id] = bundles;
|
||||
return result;
|
||||
}, [allClusters]);
|
||||
|
||||
// Collect all unique bundle names from every cluster.
|
||||
const unifiedBundles = useMemo<Array<UnifiedBundle>>(() => {
|
||||
const seen: Map<string, UnifiedBundle> = new Map();
|
||||
for (const cwb of allClusters)
|
||||
for (const archive of cwb.bundles) {
|
||||
const name = extractDisplayName(archive.manifest.name);
|
||||
if (!seen.has(name)) {
|
||||
const visibleEnabled = archive.manifest.files.filter(f => f.enabled && !f.hidden);
|
||||
seen.set(name, {
|
||||
displayName: name,
|
||||
art: resolveArt(cwb.cluster, versions),
|
||||
modCount: visibleEnabled.length,
|
||||
clustersWithBundle: [],
|
||||
});
|
||||
}
|
||||
seen.get(name)!.clustersWithBundle.push(cwb);
|
||||
}
|
||||
return [...seen.values()];
|
||||
}, [allClusters, versions]);
|
||||
|
||||
// Preselect defaults only for clusters chosen during onboarding.
|
||||
useEffect(() => {
|
||||
if (!import.meta.env.DEV)
|
||||
return;
|
||||
|
||||
const clusterDiagnostics = allClusters.map(({ cluster, bundles }) => ({
|
||||
clusterId: cluster.id,
|
||||
clusterName: cluster.name,
|
||||
mcVersion: cluster.mc_version,
|
||||
mcLoader: cluster.mc_loader,
|
||||
bundleCount: bundles.length,
|
||||
bundles: bundles.map(bundle => ({
|
||||
rawName: bundle.manifest.name,
|
||||
displayName: extractDisplayName(bundle.manifest.name),
|
||||
enabled: bundle.manifest.enabled,
|
||||
totalFiles: bundle.manifest.files.length,
|
||||
enabledFiles: bundle.manifest.files.filter(file => file.enabled).length,
|
||||
visibleEnabledFiles: bundle.manifest.files.filter(file => file.enabled && !file.hidden).length,
|
||||
})),
|
||||
}));
|
||||
|
||||
console.warn('[onboarding/preferences/versionCategory] bundle discovery diagnostics', {
|
||||
clusters: clusterDiagnostics,
|
||||
unifiedDisplayNames: unifiedBundles.map(bundle => bundle.displayName),
|
||||
});
|
||||
}, [allClusters, unifiedBundles]);
|
||||
|
||||
// modsPerCluster is full state, pre-seeded with all enabled mods from all enabled bundles.
|
||||
const [modsPerCluster, setModsPerCluster] = useState<Record<string, Array<ModWithBundle>>>(() => {
|
||||
return clusters.reduce((acc, cluster, i) => {
|
||||
const bundles = bundleQueries[i];
|
||||
const enabledMods = bundles
|
||||
.filter(bundle => bundle.manifest.enabled)
|
||||
.flatMap(bundle => bundle.manifest.files
|
||||
.filter(file => file.enabled)
|
||||
.map(file => ({ file, bundleName: bundle.manifest.name })));
|
||||
|
||||
acc[cluster.id] = enabledMods;
|
||||
return acc;
|
||||
}, {} as Record<string, Array<ModWithBundle>>),
|
||||
);
|
||||
}, {} as Record<string, Array<ModWithBundle>>);
|
||||
});
|
||||
|
||||
const bundlesPerCluster = useMemo(() => {
|
||||
const result: Record<number, Array<ModpackArchive>> = {};
|
||||
clusters.forEach((cluster, i) => {
|
||||
const selected = selectedClusters.some(sc => sc.mc_version === cluster.mc_version && sc.mc_loader === cluster.mc_loader);
|
||||
if (selected)
|
||||
result[cluster.id] = bundleQueries[i];
|
||||
// Card-level toggle: select/deselect all mods for this bundle across every cluster that has it.
|
||||
const toggleBundle = (ub: UnifiedBundle) => {
|
||||
setModsPerCluster((prev) => {
|
||||
const next = { ...prev };
|
||||
const firstCluster = ub.clustersWithBundle[0];
|
||||
const firstArchive = firstCluster.bundles.find(a => extractDisplayName(a.manifest.name) === ub.displayName);
|
||||
if (!firstArchive)
|
||||
return next;
|
||||
|
||||
const visibleEnabled = firstArchive.manifest.files.filter(f => f.enabled && !f.hidden);
|
||||
const currentMods = prev[firstCluster.cluster.id];
|
||||
const isCurrentlySelected = visibleEnabled.length > 0
|
||||
&& visibleEnabled.every(f => currentMods.some(m => m.file === f && m.bundleName === firstArchive.manifest.name));
|
||||
|
||||
for (const cwb of ub.clustersWithBundle) {
|
||||
const archive = cwb.bundles.find(a => extractDisplayName(a.manifest.name) === ub.displayName);
|
||||
if (!archive)
|
||||
continue;
|
||||
const enabledFiles = archive.manifest.files.filter(f => f.enabled);
|
||||
const clusterMods = prev[cwb.cluster.id];
|
||||
if (isCurrentlySelected) {
|
||||
next[cwb.cluster.id] = clusterMods.filter(m => m.bundleName !== archive.manifest.name || !enabledFiles.includes(m.file));
|
||||
}
|
||||
else {
|
||||
const toAdd = enabledFiles
|
||||
.filter(f => !clusterMods.some(m => m.file === f && m.bundleName === archive.manifest.name))
|
||||
.map(f => ({ file: f, bundleName: archive.manifest.name }));
|
||||
next[cwb.cluster.id] = [...toAdd, ...clusterMods];
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return result;
|
||||
}, [clusters, bundleQueries, selectedClusters]);
|
||||
};
|
||||
|
||||
const anySelected = Object.values(modsPerCluster).some(mods => mods.length > 0);
|
||||
|
||||
const downloadModsRef = useRef<DownloadModsRef>(null);
|
||||
const wrappedRef = useRef<DownloadModsRef>(null);
|
||||
|
||||
useImperativeHandle(wrappedRef, () => ({
|
||||
async openDownloadDialog(nextPath?: string) {
|
||||
// Extract overrides from all enabled bundles before downloading mods
|
||||
for (const [clusterId, bundles] of Object.entries(bundlesPerCluster))
|
||||
for (const bundle of bundles)
|
||||
if (bundle.manifest.enabled)
|
||||
try {
|
||||
await bindings.oneclient.extractBundleOverrides(bundle.path, Number(clusterId));
|
||||
}
|
||||
catch (e) {
|
||||
console.error(`Failed to extract overrides for bundle ${bundle.manifest.name}:`, e);
|
||||
}
|
||||
|
||||
// Then delegate to the actual mod download dialog
|
||||
for (const bundle of bundles) {
|
||||
const selectedMods = modsPerCluster[clusterId] ?? [];
|
||||
const hasSelectedBundleContent = selectedMods.some(mod => mod.bundleName === bundle.manifest.name);
|
||||
if (!hasSelectedBundleContent)
|
||||
continue;
|
||||
try {
|
||||
await bindings.oneclient.extractBundleOverrides(bundle.path, Number(clusterId));
|
||||
}
|
||||
catch (e) {
|
||||
console.error(`Failed to extract overrides for bundle ${bundle.manifest.name}:`, e);
|
||||
}
|
||||
}
|
||||
downloadModsRef.current?.openDownloadDialog(nextPath);
|
||||
},
|
||||
}), [bundlesPerCluster]);
|
||||
}), [bundlesPerCluster, modsPerCluster]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -116,16 +191,34 @@ function RouteComponent() {
|
|||
<div className="max-w-6xl mx-auto">
|
||||
<OverlayScrollbarsComponent>
|
||||
<div className="h-164">
|
||||
<div className="flex flex-col gap-6">
|
||||
{Object.entries(bundlesData).map(([name, bundleData]) => (
|
||||
<ModCategory
|
||||
bundleData={bundleData}
|
||||
key={name}
|
||||
modsPerCluster={modsPerCluster}
|
||||
name={name}
|
||||
setModsPerCluster={setModsPerCluster}
|
||||
/>
|
||||
))}
|
||||
<h1 className="text-4xl font-semibold mb-2">Bundles</h1>
|
||||
<p className="text-slate-400 text-lg mb-4">
|
||||
Choose which mod bundles to install. Your selection will be applied across all available versions.
|
||||
</p>
|
||||
|
||||
<div className="bg-page-elevated p-4 rounded-xl grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 gap-6">
|
||||
{unifiedBundles.map((ub) => {
|
||||
const firstCwb = ub.clustersWithBundle[0];
|
||||
const firstArchive = firstCwb.bundles.find(a => extractDisplayName(a.manifest.name) === ub.displayName);
|
||||
const visibleEnabled = firstArchive?.manifest.files.filter(f => f.enabled && !f.hidden) ?? [];
|
||||
const currentMods = modsPerCluster[firstCwb.cluster.id];
|
||||
const isSelected = visibleEnabled.length > 0
|
||||
&& visibleEnabled.every(f => currentMods.some(m => m.file === f && m.bundleName === firstArchive?.manifest.name));
|
||||
|
||||
return (
|
||||
<BundleCard
|
||||
art={ub.art}
|
||||
clustersWithBundle={ub.clustersWithBundle}
|
||||
displayName={ub.displayName}
|
||||
isSelected={isSelected}
|
||||
key={ub.displayName}
|
||||
modCount={ub.modCount}
|
||||
modsPerCluster={modsPerCluster}
|
||||
onToggle={() => toggleBundle(ub)}
|
||||
setModsPerCluster={setModsPerCluster}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="hidden">
|
||||
|
|
@ -135,59 +228,112 @@ function RouteComponent() {
|
|||
</OverlayScrollbarsComponent>
|
||||
</div>
|
||||
</div>
|
||||
<OnboardingNavigation disableNext={selectedClusters.length === 0} ref={wrappedRef} />
|
||||
<OnboardingNavigation disableNext={!anySelected} ref={wrappedRef} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ModCategory({ bundleData, name, modsPerCluster, setModsPerCluster }: { bundleData: BundleData; name: string; modsPerCluster: Record<string, Array<ModWithBundle>>; setModsPerCluster: React.Dispatch<React.SetStateAction<Record<string, Array<ModWithBundle>>>> }) {
|
||||
function BundleCard({ displayName, art, modCount, isSelected, onToggle, clustersWithBundle, modsPerCluster, setModsPerCluster }: {
|
||||
displayName: string;
|
||||
art: string;
|
||||
modCount: number;
|
||||
isSelected: boolean;
|
||||
onToggle: () => void;
|
||||
clustersWithBundle: Array<ClusterWithBundles>;
|
||||
modsPerCluster: Record<string, Array<ModWithBundle>>;
|
||||
setModsPerCluster: React.Dispatch<React.SetStateAction<Record<string, Array<ModWithBundle>>>>;
|
||||
}) {
|
||||
const artSrc = useCachedImage(art);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold my-2">{name}</h1>
|
||||
<div className="bg-page-elevated p-4 rounded-xl grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 gap-6">
|
||||
{bundleData.bundles.map(bundle => (
|
||||
<ModCategoryCard
|
||||
art={bundleData.art}
|
||||
bundle={bundle}
|
||||
clusterId={bundleData.clusterId}
|
||||
fullVersionName={bundle.manifest.name.match(/\[(.*?)\]/)?.[1] ?? 'Loading...'}
|
||||
key={bundle.manifest.name}
|
||||
mods={modsPerCluster[bundleData.clusterId]}
|
||||
setMods={newMods => setModsPerCluster(prev => ({ ...prev, [bundleData.clusterId]: typeof newMods === 'function' ? newMods(prev[bundleData.clusterId]) : newMods }))}
|
||||
/>
|
||||
))}
|
||||
<AriaButton
|
||||
className={twMerge(
|
||||
'group cursor-pointer w-full rounded-xl transition-[outline] outline-2 hover:outline-brand',
|
||||
isSelected ? 'outline-brand' : 'outline-ghost-overlay',
|
||||
)}
|
||||
onPress={onToggle}
|
||||
>
|
||||
<div className="relative w-full">
|
||||
{artSrc
|
||||
? (
|
||||
<img
|
||||
alt={`${displayName} bundle`}
|
||||
className={twMerge(
|
||||
'w-full rounded-xl h-32 object-cover transition-[filter] group-hover:brightness-100 group-hover:grayscale-0',
|
||||
isSelected ? 'brightness-100 grayscale-0' : 'brightness-70 grayscale-25',
|
||||
)}
|
||||
src={artSrc}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<div className={twMerge(
|
||||
'w-full rounded-xl h-32 bg-page-elevated transition-[filter] group-hover:brightness-100 group-hover:grayscale-0',
|
||||
isSelected ? 'brightness-100 grayscale-0' : 'brightness-70 grayscale-25',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={twMerge('absolute -top-2 right-3', isSelected ? 'block' : 'hidden group-hover:block')}>
|
||||
<div className="bg-[#D0D7F3] rounded-xl text-brand text-sm px-2 py-1">
|
||||
{modCount}
|
||||
{' '}
|
||||
Mods
|
||||
{' '}
|
||||
{isSelected ? 'Selected' : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModCardContext.Provider value={BUNDLE_CARD_CONTEXT}>
|
||||
<Overlay.Trigger>
|
||||
<Button
|
||||
className="absolute bottom-3 right-3 p-1 transition-colors"
|
||||
color="ghost"
|
||||
size="icon"
|
||||
>
|
||||
<DotsVerticalIcon className="w-4 h-4 text-white" />
|
||||
</Button>
|
||||
|
||||
<Overlay>
|
||||
<BundleDetailModal
|
||||
clustersWithBundle={clustersWithBundle}
|
||||
displayName={displayName}
|
||||
modsPerCluster={modsPerCluster}
|
||||
setModsPerCluster={setModsPerCluster}
|
||||
/>
|
||||
</Overlay>
|
||||
</Overlay.Trigger>
|
||||
</ModCardContext.Provider>
|
||||
|
||||
<div className="absolute bottom-3 left-3">
|
||||
<span className="text-white font-bold px-3 py-1 text-xl drop-shadow-lg">{displayName}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AriaButton>
|
||||
);
|
||||
}
|
||||
|
||||
function ModCategoryCard({ art, fullVersionName, bundle, mods, setMods, clusterId }: { fullVersionName: string; art: string | null | undefined; bundle: ModpackArchive; mods: Array<ModWithBundle>; setMods: React.Dispatch<React.SetStateAction<Array<ModWithBundle>>>; clusterId: number }) {
|
||||
const artSrc = useCachedImage(art);
|
||||
const files = bundle.manifest.files;
|
||||
const enabledFiles = files.filter(file => file.enabled);
|
||||
const hiddenEnabledFiles = enabledFiles.filter(file => file.hidden);
|
||||
const visibleEnabledFiles = enabledFiles.filter(file => !file.hidden);
|
||||
const bundleName = bundle.manifest.name;
|
||||
function BundleDetailModal({ displayName, clustersWithBundle, modsPerCluster, setModsPerCluster }: {
|
||||
displayName: string;
|
||||
clustersWithBundle: Array<ClusterWithBundles>;
|
||||
modsPerCluster: Record<string, Array<ModWithBundle>>;
|
||||
setModsPerCluster: React.Dispatch<React.SetStateAction<Record<string, Array<ModWithBundle>>>>;
|
||||
}) {
|
||||
const [selectedClusterId, setSelectedClusterId] = useState<number>(clustersWithBundle[0].cluster.id);
|
||||
|
||||
const isSelected = visibleEnabledFiles.length > 0
|
||||
&& visibleEnabledFiles.every(file => mods.some(m => m.file === file && m.bundleName === bundleName));
|
||||
const selected = clustersWithBundle.find(cwb => cwb.cluster.id === selectedClusterId) ?? clustersWithBundle[0];
|
||||
const matchingArchive = selected.bundles.find(a => extractDisplayName(a.manifest.name) === displayName);
|
||||
|
||||
const handleDownload = () => {
|
||||
setMods((prevMods) => {
|
||||
if (isSelected) {
|
||||
return prevMods.filter(mod => mod.bundleName !== bundleName || !enabledFiles.includes(mod.file));
|
||||
}
|
||||
else {
|
||||
const filesToAdd = enabledFiles
|
||||
.filter(file => !prevMods.some(m => m.file === file && m.bundleName === bundleName))
|
||||
.map(file => ({ file, bundleName }));
|
||||
return [...filesToAdd, ...prevMods];
|
||||
}
|
||||
});
|
||||
};
|
||||
const hiddenEnabledFiles: Array<ModpackFile> = useMemo(() => {
|
||||
if (!matchingArchive)
|
||||
return [];
|
||||
return matchingArchive.manifest.files.filter(f => f.enabled && f.hidden);
|
||||
}, [matchingArchive]);
|
||||
|
||||
const onClickOnMod: onClickOnMod = useCallback((file: ModpackFile) => {
|
||||
setMods((prevMods) => {
|
||||
const bundleName = matchingArchive?.manifest.name ?? '';
|
||||
|
||||
const onClickOnModCb: onClickOnMod = useCallback((file: ModpackFile) => {
|
||||
setModsPerCluster((prev) => {
|
||||
const prevMods = prev[selectedClusterId];
|
||||
const existingIndex = prevMods.findIndex(m => m.file === file && m.bundleName === bundleName);
|
||||
const nextMods = existingIndex >= 0
|
||||
? prevMods.filter((_, i) => i !== existingIndex)
|
||||
|
|
@ -199,65 +345,81 @@ function ModCategoryCard({ art, fullVersionName, bundle, mods, setMods, clusterI
|
|||
&& !mod.file.hidden
|
||||
));
|
||||
|
||||
if (!hasVisibleSelection)
|
||||
return nextMods.filter(mod => mod.bundleName !== bundleName || !hiddenEnabledFiles.includes(mod.file));
|
||||
const synced = !hasVisibleSelection
|
||||
? nextMods.filter(mod => mod.bundleName !== bundleName || !hiddenEnabledFiles.includes(mod.file))
|
||||
: [
|
||||
...hiddenEnabledFiles
|
||||
.filter(hf => !nextMods.some(m => m.file === hf && m.bundleName === bundleName))
|
||||
.map(hf => ({ file: hf, bundleName })),
|
||||
...nextMods,
|
||||
];
|
||||
|
||||
const hiddenToAdd = hiddenEnabledFiles
|
||||
.filter(hiddenFile => !nextMods.some(mod => mod.file === hiddenFile && mod.bundleName === bundleName))
|
||||
.map(hiddenFile => ({ file: hiddenFile, bundleName }));
|
||||
|
||||
return [...hiddenToAdd, ...nextMods];
|
||||
return { ...prev, [selectedClusterId]: synced };
|
||||
});
|
||||
}, [bundleName, hiddenEnabledFiles, setMods]);
|
||||
}, [selectedClusterId, bundleName, hiddenEnabledFiles, setModsPerCluster]);
|
||||
|
||||
const modsForContext = mods.filter(m => !m.file.hidden).map(m => m.file);
|
||||
const visibleMods = useMemo(() => {
|
||||
return modsPerCluster[selectedClusterId].filter(m => !m.file.hidden).map(m => m.file);
|
||||
}, [modsPerCluster, selectedClusterId]);
|
||||
|
||||
const context = useMemo<ModCardContextApi>(() => ({
|
||||
onClickOnMod,
|
||||
onClickOnMod: onClickOnModCb,
|
||||
useVerticalGridLayout: true,
|
||||
mods: modsForContext,
|
||||
}), [modsForContext, onClickOnMod]);
|
||||
mods: visibleMods,
|
||||
}), [onClickOnModCb, visibleMods]);
|
||||
|
||||
return (
|
||||
<AriaButton className={twMerge('group cursor-pointer w-full rounded-xl transition-[outline] outline-2 hover:outline-brand', isSelected ? 'outline-brand' : 'outline-ghost-overlay')} onPress={handleDownload}>
|
||||
<div className="relative w-full">
|
||||
{artSrc
|
||||
? (
|
||||
<img
|
||||
alt={`Minecraft ${fullVersionName} landscape`}
|
||||
className={twMerge('w-full rounded-xl h-32 object-cover transition-[filter] group-hover:brightness-100 group-hover:grayscale-0', isSelected ? 'brightness-100 grayscale-0' : 'brightness-70 grayscale-25')}
|
||||
src={artSrc}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<div className={twMerge('w-full rounded-xl h-32 bg-page-elevated transition-[filter] group-hover:brightness-100 group-hover:grayscale-0', isSelected ? 'brightness-100 grayscale-0' : 'brightness-70 grayscale-25')} />
|
||||
)}
|
||||
<Overlay.Dialog className="bg-page items-start flex flex-col min-w-160">
|
||||
<Overlay.Title className="px-4">
|
||||
Mods for
|
||||
{' '}
|
||||
<span className="text-brand">{displayName}</span>
|
||||
</Overlay.Title>
|
||||
|
||||
<div className={twMerge('absolute -top-2 right-3', isSelected ? 'block' : 'hidden group-hover:block')}>
|
||||
<div className="bg-[#D0D7F3] rounded-xl text-brand text-sm px-2 py-1">
|
||||
{visibleEnabledFiles.length} Mods {isSelected ? 'Selected' : ''}
|
||||
</div>
|
||||
{clustersWithBundle.length > 1 && (
|
||||
<div className="flex flex-row gap-2 px-4 pb-3 flex-wrap">
|
||||
{clustersWithBundle.map(cwb => (
|
||||
<AriaButton
|
||||
className={twMerge(
|
||||
'px-3 py-1 rounded-lg text-sm transition-colors',
|
||||
cwb.cluster.id === selectedClusterId
|
||||
? 'bg-brand text-white'
|
||||
: 'bg-component-bg text-fg-secondary hover:bg-component-bg-hover',
|
||||
)}
|
||||
key={cwb.cluster.id}
|
||||
onPress={() => setSelectedClusterId(cwb.cluster.id)}
|
||||
>
|
||||
{cwb.cluster.mc_version}
|
||||
</AriaButton>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ModCardContext.Provider value={context}>
|
||||
<Overlay.Trigger>
|
||||
<Button className="absolute bottom-3 right-3 p-1 transition-colors" color="ghost" size="icon">
|
||||
<DotsVerticalIcon className="w-4 h-4 text-white" />
|
||||
</Button>
|
||||
|
||||
<Overlay>
|
||||
<BundleModListModal clusterId={clusterId} name={fullVersionName} />
|
||||
</Overlay>
|
||||
</Overlay.Trigger>
|
||||
</ModCardContext.Provider>
|
||||
|
||||
<div className="absolute bottom-3 left-3">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<span className="text-white font-bold px-3 py-1 text-xl">{fullVersionName}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ModCardContext.Provider value={context}>
|
||||
<ModList
|
||||
bundles={selected.bundles}
|
||||
cluster={selected.cluster}
|
||||
selectedTab={displayName}
|
||||
/>
|
||||
</ModCardContext.Provider>
|
||||
|
||||
<div className="w-full flex flex-row gap-6 items-center justify-end px-4 pt-3">
|
||||
<p className="font-normal text-lg text-fg-secondary">
|
||||
Selected
|
||||
{' '}
|
||||
{visibleMods.length}
|
||||
{' '}
|
||||
mods
|
||||
</p>
|
||||
<Button
|
||||
className="px-12"
|
||||
color="primary"
|
||||
size="large"
|
||||
slot="close"
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</div>
|
||||
</AriaButton>
|
||||
</Overlay.Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue