unify version downloads

This commit is contained in:
Wyvest 2026-03-30 01:19:15 -04:00
parent 5cac13a54c
commit 1c3041679e
No known key found for this signature in database
3 changed files with 444 additions and 234 deletions

View file

@ -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 }

View file

@ -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>
);
}

View file

@ -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>
);
}