incredible art update

This commit is contained in:
Wyvest 2026-02-25 05:44:05 -05:00
parent 8d1b69d7a0
commit ca4cea2ffb
13 changed files with 281 additions and 44 deletions

View file

@ -53,6 +53,12 @@ pub trait OneClientApi {
#[taurpc(alias = "isBundleSyncing")]
async fn is_bundle_syncing() -> LauncherResult<bool>;
#[taurpc(alias = "cacheArt")]
async fn cache_art(path: String) -> LauncherResult<String>;
#[taurpc(alias = "refreshArt")]
async fn refresh_art(path: String) -> LauncherResult<()>;
}
#[taurpc::ipc_type]
@ -206,4 +212,15 @@ impl OneClientApi for OneClientApiImpl {
async fn is_bundle_syncing(self) -> LauncherResult<bool> {
Ok(crate::oneclient::is_bundle_syncing())
}
async fn cache_art(self, path: String) -> LauncherResult<String> {
crate::oneclient::clusters::cache_art_image(&path).await
}
async fn refresh_art(self, path: String) -> LauncherResult<()> {
tokio::spawn(async move {
crate::oneclient::clusters::refresh_art_cache(&path).await;
});
Ok(())
}
}

View file

@ -6,4 +6,4 @@ pub const DISCORD_CLIENT_ID: &'static str = "1426999264633946334";
// === Meta ===
// Cannot contain trailing slash
pub const META_URL_BASE: &'static str =
"https://raw.githubusercontent.com/PolyFrost/DataStorage/refs/heads/main/oneclient";
"https://raw.githubusercontent.com/Polyfrost/DataStorage/refs/heads/main/oneclient";

View file

@ -96,7 +96,9 @@ async fn initialize_state(handle: &tauri::AppHandle) -> LauncherResult<()> {
State::get().await?;
onelauncher_core::api::credentials::refresh_accounts().await?;
if let Err(err) = onelauncher_core::api::credentials::refresh_accounts().await {
tracing::warn!("failed to refresh accounts on startup: {err}");
}
tracing::info!("initialized launcher successfully");
Ok(())

View file

@ -4,7 +4,8 @@ use onelauncher_core::entity::clusters::Model as ClusterModel;
use onelauncher_core::entity::loader::GameLoader;
use onelauncher_core::error::LauncherResult;
use onelauncher_core::send_error;
use onelauncher_core::utils::http::fetch_json;
use onelauncher_core::store::Dirs;
use onelauncher_core::utils::http::{fetch, fetch_json};
use reqwest::Method;
use serde::{Deserialize, Serialize};
@ -61,6 +62,10 @@ pub struct OnlineCluster {
#[derive(specta::Type, Deserialize, Serialize)]
pub struct OnlineClusterEntry {
minor_version: u8,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
art: Option<String>,
loader: GameLoader,
tags: Vec<String>,
}
@ -84,6 +89,50 @@ pub async fn get_data_storage_versions() -> LauncherResult<OnlineClusterManifest
Ok(manifest)
}
/// Download an art image (e.g. `/versions/art/Foo.png`) from the data storage CDN and
/// save it to the local cache directory. Returns the OS-native path to the cached file,
/// which the frontend converts to an `asset://` URL via `convertFileSrc`.
/// If the file is already cached it is returned immediately without a network request.
pub async fn cache_art_image(path: &str) -> LauncherResult<String> {
let art_dir = Dirs::get_caches_dir().await?.join("oneclient").join("art");
tokio::fs::create_dir_all(&art_dir)
.await
.map_err(anyhow::Error::from)?;
// Flatten "/versions/art/Foo.png" → "versions_art_Foo.png"
let filename = path.trim_start_matches('/').replace('/', "_");
let cached_path = art_dir.join(&filename);
if cached_path.exists() {
return Ok(cached_path.to_string_lossy().into_owned());
}
let url = format!("{}{}", crate::constants::META_URL_BASE, path);
let bytes = fetch(Method::GET, &url).await?;
tokio::fs::write(&cached_path, bytes)
.await
.map_err(anyhow::Error::from)?;
Ok(cached_path.to_string_lossy().into_owned())
}
/// Re-download an art image and overwrite the on-disk cache.
/// Errors are silently ignored — the existing cached version is kept on failure.
/// Call this from `tokio::spawn` so it runs fully in the background.
pub async fn refresh_art_cache(path: &str) {
let Ok(caches_dir) = Dirs::get_caches_dir().await else {
return;
};
let art_dir = caches_dir.join("oneclient").join("art");
let filename = path.trim_start_matches('/').replace('/', "_");
let cached_path = art_dir.join(&filename);
let url = format!("{}{}", crate::constants::META_URL_BASE, path);
if let Ok(bytes) = fetch(Method::GET, &url).await {
let _ = tokio::fs::write(&cached_path, bytes).await;
}
}
pub async fn init_clusters() -> LauncherResult<()> {
let manifest = match fetch_json::<OnlineClusterManifest>(
Method::GET,

View file

@ -127,7 +127,7 @@ export type MowojangProfile = { id: string; username: string }
export type OnlineCluster = { major_version: number; name: string; art: string; entries: OnlineClusterEntry[] }
export type OnlineClusterEntry = { minor_version: number; loader: GameLoader; tags: string[] }
export type OnlineClusterEntry = { minor_version: number; name?: string | null; art?: string | null; loader: GameLoader; tags: string[] }
/**
* e.g.
@ -313,8 +313,11 @@ export type VersionType =
*/
"old_beta"
const ARGS_MAP = { 'core':'{"removeUser":["uuid"],"getClusterById":["id"],"togglePackage":["cluster_id","package_hash"],"getUsersFromAuthor":["provider","author"],"fetchLoggedInProfile":["access_token"],"getClusters":[],"open":["input"],"changeCape":["access_token","cape_uuid"],"refreshAccount":["uuid"],"getLinkedPackages":["cluster_id"],"getUsers":[],"getProfileOrDefault":["name"],"fetchMinecraftProfile":["uuid"],"getLoadersForVersion":["mc_version"],"writeSettings":["setting"],"getDefaultUser":["fallback"],"openMsaLogin":[],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"getUser":["uuid"],"removeCape":["access_token"],"createSettingsProfile":["name"],"getRunningProcessesByClusterId":["cluster_id"],"updateClusterById":["id","request"],"getGameVersions":[],"launchCluster":["id","uuid","search_for_java"],"getRunningProcesses":[],"refreshAccounts":[],"getWorlds":["id"],"getScreenshots":["id"],"updateClusterProfile":["name","profile"],"searchPackages":["provider","query"],"setDefaultUser":["uuid"],"readSettings":[],"getPackage":["provider","slug"],"getLogByName":["id","name"],"getMultiplePackages":["provider","slugs"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"syncCluster":["cluster_id"],"isClusterRunning":["cluster_id"],"downloadExternalPackage":["package","cluster_id","force","skip_compatibility"],"removePackage":["cluster_id","package_hash"],"installModpack":["modpack","cluster_id"],"killProcess":["pid"],"uploadSkinBytes":["access_token","skin_data","image_format","skin_variant"],"setDiscordRPCMessage":["message"],"removeCluster":["id"],"getProcessLogTail":["id","max_lines"],"changeSkin":["access_token","skin_url","skin_variant"],"createCluster":["options"],"setClusterStage":["id","stage"],"getGlobalProfile":[],"getLogs":["id"],"getPackageBody":["provider","body"],"convertUsernameUUID":["username_uuid"]}', 'debug':'{"openDevTools":[],"isInDev":[],"getPlatform":[],"getPackageVersion":[],"getArch":[],"getType":[],"getLocale":[],"getOsVersion":[],"getFamily":[],"getGitCommitHash":[],"getBuildTimestamp":[]}', 'folders':'{"fromCluster":["folder_name"],"openCluster":["folder_name"]}', 'events':'{"ingress":["event"],"message":["event"],"process":["event"]}', 'oneclient':'{"updateBundlePackages":["cluster_id"],"isBundleSyncing":[],"getVersions":[],"downloadPackageFromBundle":["package","cluster_id","bundle_name","skip_compatibility"],"getClustersGroupedByMajor":[],"checkForUpdate":[],"getBundlesFor":["cluster_id"],"extractBundleOverrides":["bundle_path","cluster_id"],"installUpdate":[]}' }
export type Router = { 'debug': { openDevTools: () => Promise<void>,
const ARGS_MAP = { 'oneclient':'{"refreshArt":["path"],"extractBundleOverrides":["bundle_path","cluster_id"],"downloadPackageFromBundle":["package","cluster_id","bundle_name","skip_compatibility"],"updateBundlePackages":["cluster_id"],"getBundlesFor":["cluster_id"],"getVersions":[],"getClustersGroupedByMajor":[],"checkForUpdate":[],"installUpdate":[],"cacheArt":["path"],"isBundleSyncing":[]}', 'debug':'{"getBuildTimestamp":[],"getPackageVersion":[],"getPlatform":[],"isInDev":[],"getArch":[],"getFamily":[],"openDevTools":[],"getGitCommitHash":[],"getType":[],"getLocale":[],"getOsVersion":[]}', 'folders':'{"fromCluster":["folder_name"],"openCluster":["folder_name"]}', 'core':'{"createSettingsProfile":["name"],"getPackageBody":["provider","body"],"getUsers":[],"launchCluster":["id","uuid","search_for_java"],"getUsersFromAuthor":["provider","author"],"syncCluster":["cluster_id"],"getLinkedPackages":["cluster_id"],"changeSkin":["access_token","skin_url","skin_variant"],"getMultiplePackages":["provider","slugs"],"removeCluster":["id"],"togglePackage":["cluster_id","package_hash"],"installModpack":["modpack","cluster_id"],"uploadSkinBytes":["access_token","skin_data","image_format","skin_variant"],"fetchMinecraftProfile":["uuid"],"getScreenshots":["id"],"updateClusterProfile":["name","profile"],"getRunningProcessesByClusterId":["cluster_id"],"getProfileOrDefault":["name"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"getProcessLogTail":["id","max_lines"],"getClusterById":["id"],"isClusterRunning":["cluster_id"],"open":["input"],"killProcess":["pid"],"fetchLoggedInProfile":["access_token"],"openMsaLogin":[],"writeSettings":["setting"],"changeCape":["access_token","cape_uuid"],"createCluster":["options"],"setClusterStage":["id","stage"],"searchPackages":["provider","query"],"setDiscordRPCMessage":["message"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"removePackage":["cluster_id","package_hash"],"getClusters":[],"getWorlds":["id"],"getDefaultUser":["fallback"],"getPackage":["provider","slug"],"setDefaultUser":["uuid"],"getRunningProcesses":[],"getGlobalProfile":[],"updateClusterById":["id","request"],"refreshAccounts":[],"getGameVersions":[],"getUser":["uuid"],"convertUsernameUUID":["username_uuid"],"refreshAccount":["uuid"],"removeCape":["access_token"],"downloadExternalPackage":["package","cluster_id","force","skip_compatibility"],"removeUser":["uuid"],"getLogs":["id"],"getLogByName":["id","name"],"getLoadersForVersion":["mc_version"],"readSettings":[]}', 'events':'{"process":["event"],"ingress":["event"],"message":["event"]}' }
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>,
@ -333,12 +336,9 @@ 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> },
'events': { ingress: (event: IngressPayload) => Promise<void>,
message: (event: MessagePayload) => Promise<void>,
process: (event: ProcessPayload) => Promise<void> },
'folders': { fromCluster: (folderName: string) => Promise<string>,
openCluster: (folderName: string) => Promise<null> },
isBundleSyncing: () => Promise<boolean>,
cacheArt: (path: string) => Promise<string>,
refreshArt: (path: string) => Promise<null> },
'core': { getClusters: () => Promise<ClusterModel[]>,
getClusterById: (id: number) => Promise<ClusterModel | null>,
removeCluster: (id: number) => Promise<null>,
@ -392,7 +392,9 @@ changeCape: (accessToken: string, capeUuid: string) => Promise<MojangFullPlayerP
removeCape: (accessToken: string) => Promise<MojangFullPlayerProfile>,
convertUsernameUUID: (usernameUuid: string) => Promise<MowojangProfile>,
setDiscordRPCMessage: (message: string) => Promise<null>,
open: (input: string) => Promise<null> } };
open: (input: string) => Promise<null> },
'folders': { fromCluster: (folderName: string) => Promise<string>,
openCluster: (folderName: string) => Promise<null> } };
export type { InferCommandOutput }

View file

@ -0,0 +1,50 @@
import { bindings } from '@/main';
import { convertFileSrc } from '@tauri-apps/api/core';
import { useEffect, useState } from 'react';
// Track which paths have already been refreshed this session so we only
// hit the network once per path, not on every component mount.
const refreshedPaths: Set<string> = new Set();
/**
* Returns the best URL for an art image at the given relative path (e.g. `/versions/art/Foo.png`).
*
* - If the image is already cached on disk, returns the local `asset://` URL immediately.
* - If not cached, downloads it first, then returns the local URL.
* - After the cached version is shown, triggers a one-per-session background refresh that
* overwrites the on-disk cache with any updated version — applied on the next app start.
*/
export function useCachedImage(path: string | null | undefined): string | undefined {
const [src, setSrc] = useState<string | undefined>(undefined);
useEffect(() => {
setSrc(undefined);
if (!path)
return;
let cancelled = false;
bindings.oneclient.cacheArt(path)
.then((localPath: string) => {
if (cancelled)
return;
setSrc(convertFileSrc(localPath));
// One background refresh per path per session — no UI update, just overwrites disk
if (!refreshedPaths.has(path)) {
refreshedPaths.add(path);
bindings.oneclient.refreshArt(path).catch(() => {});
}
})
.catch(() => {
// Not cached and offline — nothing to show
});
return () => {
cancelled = true;
};
}, [path]);
return src;
}

View file

@ -1,5 +1,6 @@
import type { GameLoader } from '@/bindings.gen';
import { GameBackground, LaunchButton, SheetPage } from '@/components';
import { useCachedImage } from '@/hooks/useCachedImage';
import { bindings } from '@/main';
import useClusterStore from '@/stores/clusterStore';
import { prettifyLoader } from '@/utils/loaders';
@ -24,6 +25,7 @@ function RouteComponent() {
const navigate = Route.useNavigate();
const { data: clusters } = useCommandSuspense(['getClustersGroupedByMajor'], bindings.oneclient.getClustersGroupedByMajor);
const { data: versions } = useCommandSuspense(['getVersions'], () => bindings.oneclient.getVersions());
const loadMinorVersion = useCallback((major: number) => {
const existing = prevMinorVersions.find(v => v.major === major);
@ -101,6 +103,23 @@ function RouteComponent() {
});
}, [clusters, majorVersion, effectiveMinorVersion, modLoader]);
const selectedMinorArtPath = useMemo(() => {
const onlineCluster = versions.clusters.find(c => c.major_version === majorVersion);
if (!onlineCluster)
return undefined;
const selectedMinor = parseMcVersion(cluster?.mc_version)?.minor;
if (selectedMinor === undefined)
return onlineCluster.art;
const loaderMatchedEntry = onlineCluster.entries.find(entry => entry.minor_version === selectedMinor && entry.loader === cluster?.mc_loader);
const minorMatchedEntry = onlineCluster.entries.find(entry => entry.minor_version === selectedMinor);
return loaderMatchedEntry?.art ?? minorMatchedEntry?.art ?? onlineCluster.art;
}, [versions.clusters, majorVersion, cluster?.mc_version, cluster?.mc_loader]);
const selectedMinorArtSrc = useCachedImage(selectedMinorArtPath);
const versionInfo = useMemo(() => getVersionInfoOrDefault(cluster?.mc_version), [cluster]);
const view = useCallback(() => {
@ -142,13 +161,11 @@ function RouteComponent() {
{Object.keys(clusters).map((majorStr) => {
const major = Number.parseInt(majorStr, 10);
const isSelected = major === majorVersion;
const info = getVersionInfo(major);
if (!info)
return undefined;
const onlineCluster = versions.clusters.find(c => c.major_version === major);
return (
<ClusterEntry
artPath={onlineCluster?.art}
isSelected={isSelected}
key={major}
major_version={major}
@ -161,7 +178,17 @@ function RouteComponent() {
</div>
<SheetPage.Content className="sticky top-8 w-86 h-min flex flex-col p-2 gap-2 outline outline-ghost-overlay">
<GameBackground className="aspect-video w-full rounded-xl outline-2 outline-ghost-overlay" name={versionInfo.backgroundName} />
{selectedMinorArtSrc
? (
<img
alt={`Minecraft ${versionInfo.prettyName} landscape`}
className="aspect-video w-full rounded-xl outline-2 outline-ghost-overlay object-cover"
src={selectedMinorArtSrc}
/>
)
: (
<GameBackground className="aspect-video w-full rounded-xl outline-2 outline-ghost-overlay" name={versionInfo.backgroundName} />
)}
<div className="flex flex-col px-4 pt-2 pb-4 gap-2">
<h2 className="text-xxl font-medium">Version {cluster.mc_version}</h2>
@ -177,7 +204,7 @@ function RouteComponent() {
setActiveMinorVersion(Number(selected));
setMinorVersion(majorVersion, Number(selected));
}}
selectedKey={effectiveMinorVersion}
selectedKey={effectiveMinorVersion}
>
{minorVersions.map((minorVersion) => {
const fullVer = `${versionInfo.prettyName}.${minorVersion}`;
@ -201,12 +228,12 @@ function RouteComponent() {
<Dropdown
aria-label="Mod Loader Dropdown"
onSelectionChange={(selected) => {
if (!effectiveMinorVersion)
return;
if (!effectiveMinorVersion)
return;
setModLoader(`${majorVersion}.${effectiveMinorVersion}`, selected as GameLoader);
}}
selectedKey={effectiveMinorVersion ? (modLoaders[`${majorVersion}.${effectiveMinorVersion}`] ?? loaders[0]) : loaders[0]}
setModLoader(`${majorVersion}.${effectiveMinorVersion}`, selected as GameLoader);
}}
selectedKey={effectiveMinorVersion ? (modLoaders[`${majorVersion}.${effectiveMinorVersion}`] ?? loaders[0]) : loaders[0]}
>
{loaders.map(loader => (
<Dropdown.Item
@ -259,16 +286,19 @@ function HeaderSmall() {
}
function ClusterEntry({
artPath,
isSelected,
major_version,
onClick,
tags,
}: {
artPath: string | null | undefined;
isSelected: boolean;
major_version: number;
onClick: () => unknown;
tags: Array<string>;
}) {
const artSrc = useCachedImage(artPath);
const info = getVersionInfo(major_version);
if (!info)
@ -276,7 +306,17 @@ function ClusterEntry({
return (
<div className={twMerge('flex flex-col justify-between relative aspect-video transition-[filter] px-4', !isSelected && 'brightness-70 grayscale-25 hover:brightness-80 hover:grayscale-0')} key={major_version} onClick={onClick}>
<GameBackground className={twMerge('opacity-90 absolute w-full h-full -z-10 rounded-md transition-[outline] outline-2', isSelected ? 'outline-brand' : 'outline-ghost-overlay')} name={info.backgroundName} />
{artSrc
? (
<img
alt={`Minecraft ${info.prettyName} landscape`}
className={twMerge('opacity-90 absolute inset-0 w-full h-full -z-10 rounded-md transition-[outline] outline-2 object-cover', isSelected ? 'outline-brand' : 'outline-ghost-overlay')}
src={artSrc}
/>
)
: (
<GameBackground className={twMerge('opacity-90 absolute w-full h-full -z-10 rounded-md transition-[outline] outline-2', isSelected ? 'outline-brand' : 'outline-ghost-overlay')} name={info.backgroundName} />
)}
<div
className="absolute -z-10 top-0 left-0 w-full h-full rounded-md overflow-hidden " style={{
background: 'radial-gradient(48.93% 47.95% at 49.92% 28.42%, rgba(0, 0, 0, 0.00) 0%, rgba(0, 0, 0, 0.30) 52.26%, rgba(0, 0, 0, 0.60) 100%)',

View file

@ -1,12 +1,13 @@
import type { ClusterModel, GameLoader } from '@/bindings.gen';
import type { ClusterModel, GameLoader, OnlineClusterManifest } from '@/bindings.gen';
import type { ButtonProps } from 'react-aria-components';
import { GameBackground, LaunchButton } from '@/components';
import { useCachedImage } from '@/hooks/useCachedImage';
import { useActiveCluster, useLastPlayedClusters } from '@/hooks/useClusters';
import { bindings } from '@/main';
import useAppShellStore from '@/stores/appShellStore';
import { prettifyLoader } from '@/utils/loaders';
import { animations } from '@/utils/motion';
import { getVersionInfo, getVersionInfoOrDefault } from '@/utils/versionMap';
import { getOnlineClusterForVersion, getOnlineEntryForVersion, getVersionInfo, getVersionInfoOrDefault } from '@/utils/versionMap';
import { useCommandSuspense } from '@onelauncher/common';
import { Button } from '@onelauncher/common/components';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
@ -15,6 +16,7 @@ import { motion } from 'motion/react';
import { Button as AriaButton } from 'react-aria-components';
import { twMerge } from 'tailwind-merge';
export const Route = createFileRoute('/app/')({
component: RouteComponent,
});
@ -23,6 +25,7 @@ function RouteComponent() {
// Preload the clusters for version page
useCommandSuspense(['getClustersGroupedByMajor'], bindings.oneclient.getClustersGroupedByMajor);
const { data: versions } = useCommandSuspense(['getVersions'], () => bindings.oneclient.getVersions());
const { data: lastPlayedClusters } = useLastPlayedClusters();
const setActiveClusterId = useAppShellStore(state => state.setActiveClusterId);
@ -34,7 +37,7 @@ function RouteComponent() {
return (
<div className="flex h-full w-full flex-col justify-center p-12">
<ActiveClusterInfo cluster={activeCluster} />
<ActiveClusterInfo cluster={activeCluster} versions={versions} />
<motion.div {...animations.slideInUp} className="flex flex-row transition-[height] h-52 gap-6">
{lastPlayedClusters.slice(0, 3).map(cluster => (
@ -44,6 +47,7 @@ function RouteComponent() {
loader={cluster.mc_loader}
onPress={() => setActiveClusterId(cluster.id)}
version={cluster.mc_version}
versions={versions}
/>
))}
@ -59,8 +63,9 @@ function RouteComponent() {
);
}
function ActiveClusterInfo({ cluster }: { cluster: ClusterModel }) {
function ActiveClusterInfo({ cluster, versions }: { cluster: ClusterModel; versions: OnlineClusterManifest }) {
const versionInfo = getVersionInfoOrDefault(cluster.mc_version);
const entry = getOnlineEntryForVersion(cluster.mc_version, versions);
const navigate = useNavigate({ from: Route.id });
const viewCluster = () => {
@ -72,6 +77,8 @@ function ActiveClusterInfo({ cluster }: { cluster: ClusterModel }) {
});
};
const subtitle = entry?.name ?? versionInfo.shortDescription;
return (
<motion.div
animate={{
@ -87,7 +94,7 @@ function ActiveClusterInfo({ cluster }: { cluster: ClusterModel }) {
transition={{ ease: 'backInOut', duration: 0.35 }}
>
<h1 className="text-6xl font-bold text-fg-primary">{cluster.mc_version} {prettifyLoader(cluster.mc_loader)}</h1>
<p className="text-lg font-medium text-fg-secondary">{versionInfo.shortDescription}</p>
<p className="text-lg font-medium text-fg-secondary">{subtitle}</p>
<div className="flex flex-row justify-center items-center gap-2">
<LaunchButton cluster={cluster} size="large" />
@ -105,6 +112,7 @@ interface RecentsCardProps {
loader: GameLoader;
onPress: () => void;
active: boolean;
versions: OnlineClusterManifest;
}
function RecentsCard({
@ -112,10 +120,16 @@ function RecentsCard({
loader,
onPress,
active,
versions,
}: RecentsCardProps) {
const versionInfo = getVersionInfo(version);
const entry = getOnlineEntryForVersion(version, versions);
const onlineCluster = getOnlineClusterForVersion(version, versions);
if (!versionInfo)
const artPath = entry?.art ?? onlineCluster?.art;
const artSrc = useCachedImage(artPath);
if (!versionInfo && !artPath)
return (
<Card blur>
<p className="text-lg font-medium text-fg-secondary">Unknown Version</p>
@ -125,7 +139,17 @@ function RecentsCard({
return (
<Card className={twMerge(active && 'outline-2 outline-brand')} onPress={onPress}>
<div className="flex w-full h-full justify-start items-end px-6 py-3 hover:brightness-80">
<GameBackground className="absolute -z-10 left-0 top-0 w-full h-full scale-110" name={versionInfo.backgroundName} />
{artSrc
? (
<img
alt={`Minecraft ${version} landscape`}
className="absolute -z-10 left-0 top-0 w-full h-full object-cover scale-110"
src={artSrc}
/>
)
: (
<GameBackground className="absolute -z-10 left-0 top-0 w-full h-full scale-110" name={versionInfo!.backgroundName} />
)}
<div
className="absolute top-0 left-0 -z-10 w-full h-full"

View file

@ -1,8 +1,11 @@
import type { PropsWithChildren } from 'react';
import { GameBackground, LoaderSuspense, Navbar } from '@/components';
import { useCachedImage } from '@/hooks/useCachedImage';
import { useAllClusterDirWatch } from '@/hooks/useClusterDirWatch';
import { useActiveCluster } from '@/hooks/useClusters';
import { getVersionInfoOrDefault } from '@/utils/versionMap';
import { bindings } from '@/main';
import { getOnlineClusterForVersion, getOnlineEntryForVersion, getVersionInfoOrDefault } from '@/utils/versionMap';
import { useCommandSuspense } from '@onelauncher/common';
import { createFileRoute, Outlet } from '@tanstack/react-router';
import { motion } from 'motion/react';
import { MouseParallax } from 'react-just-parallax';
@ -56,6 +59,12 @@ function AppShell({
function BackgroundGradient() {
const cluster = useActiveCluster();
const versionInfo = getVersionInfoOrDefault(cluster.mc_version);
const { data: versions } = useCommandSuspense(['getVersions'], () => bindings.oneclient.getVersions());
const entry = getOnlineEntryForVersion(cluster.mc_version, versions);
const onlineCluster = getOnlineClusterForVersion(cluster.mc_version, versions);
const artPath = entry?.art ?? onlineCluster?.art;
const artSrc = useCachedImage(artPath);
return (
<div className="relative">
@ -97,10 +106,20 @@ function BackgroundGradient() {
}}
key={(cluster.mc_version) + (cluster.mc_loader)}
>
<GameBackground
className="absolute left-0 top-0 w-screen h-screen scale-110"
name={versionInfo.backgroundName}
/>
{artSrc
? (
<img
alt=""
className="absolute left-0 top-0 w-screen h-screen scale-110 object-cover"
src={artSrc}
/>
)
: (
<GameBackground
className="absolute left-0 top-0 w-screen h-screen scale-110"
name={versionInfo.backgroundName}
/>
)}
</motion.div>
</MouseParallax>
</div>

View file

@ -1,5 +1,6 @@
import type { GameLoader, OnlineCluster, OnlineClusterEntry } from '@/bindings.gen';
import type { VersionInfo } from '@/utils/versionMap';
import { useCachedImage } from '@/hooks/useCachedImage';
import { bindings } from '@/main';
import { OnboardingNavigation } from '@/routes/onboarding/route';
import { getVersionInfoOrDefault } from '@/utils/versionMap';
@ -80,13 +81,16 @@ function VersionCard({ cluster, versionData, version, fullVersionName, setSelect
});
};
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">
<img
alt={`Minecraft ${versionData.prettyName} landscape`}
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={`https://raw.githubusercontent.com/PolyFrost/DataStorage/refs/heads/main/oneclient${cluster.art}`}
src={artSrc}
/>
<div className="absolute top-3 left-3 flex flex-wrap gap-1">

View file

@ -2,6 +2,7 @@ import type { ClusterModel, ModpackArchive, ModpackFile } 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 { useCachedImage } from '@/hooks/useCachedImage';
import { bindings } from '@/main';
import { OnboardingNavigation } from '@/routes/onboarding/route';
import { useCommandSuspense } from '@onelauncher/common';
@ -40,10 +41,14 @@ function RouteComponent() {
const selected = selectedClusters.some(selectedCluster => selectedCluster.mc_version === cluster.mc_version && selectedCluster.mc_loader === cluster.mc_loader);
if (!selected)
return;
const version = versions.clusters.find(version => cluster.mc_version.startsWith(`1.${version.major_version}`));
const onlineCluster = versions.clusters.find(c => cluster.mc_version.startsWith(`1.${c.major_version}`));
const minorVersion = Number.parseInt(cluster.mc_version.split('.')[2] ?? '', 10);
const onlineEntry = !Number.isNaN(minorVersion)
? onlineCluster?.entries.find(e => e.minor_version === minorVersion)
: undefined;
bundlesData[cluster.name] = {
bundles: bundleQueries[i],
art: version?.art ?? '/versions/art/Horse_Update.jpg',
art: onlineEntry?.art ?? onlineCluster?.art ?? '/versions/art/Horse_Update.jpg',
modsInfo: [],
clusterId: cluster.id,
};
@ -148,7 +153,8 @@ function ModCategory({ bundleData, name, modsPerCluster, setModsPerCluster }: {
);
}
function ModCategoryCard({ art, fullVersionName, bundle, mods, setMods, clusterId }: { fullVersionName: string; art: string; bundle: ModpackArchive; mods: Array<ModWithBundle>; setMods: React.Dispatch<React.SetStateAction<Array<ModWithBundle>>>; clusterId: number }) {
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);
@ -210,7 +216,7 @@ function ModCategoryCard({ art, fullVersionName, bundle, mods, setMods, clusterI
<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={`https://raw.githubusercontent.com/Polyfrost/DataStorage/refs/heads/main/oneclient${art}`}
src={artSrc}
/>
<div className={twMerge('absolute -top-2 right-3', isSelected ? 'block' : 'hidden group-hover:block')}>

View file

@ -1,3 +1,4 @@
import type { OnlineCluster, OnlineClusterEntry, OnlineClusterManifest } from '@/bindings.gen';
import type { GameBackgroundName } from '@/components';
export interface VersionInfo {
@ -54,6 +55,29 @@ export function getVersionInfoOrDefault(version: number | string | null | undefi
return info ?? VERSION_MAP[-1];
}
export function getOnlineClusterForVersion(
version: string | null | undefined,
versions: OnlineClusterManifest,
): OnlineCluster | undefined {
const parsed = parseMcVersion(version);
if (!parsed)
return undefined;
return versions.clusters.find(c => c.major_version === parsed.major);
}
export function getOnlineEntryForVersion(
version: string | null | undefined,
versions: OnlineClusterManifest,
): OnlineClusterEntry | undefined {
const parsed = parseMcVersion(version);
if (!parsed || parsed.minor === undefined)
return undefined;
const cluster = versions.clusters.find(c => c.major_version === parsed.major);
if (!cluster)
return undefined;
return cluster.entries.find(e => e.minor_version === parsed.minor);
}
export interface ParsedMcVersion {
major: number;
minor: number | undefined;

View file

@ -4,7 +4,7 @@
* @type {import('lint-staged').Configuration}
*/
const config = {
'*.ts': 'eslint',
'*.ts': 'eslint --no-warn-ignored',
'*.{js,mjs,cjs}': 'eslint', // handle JS, MJS, CJS in one fell swoop
'*.rs': [
'rustfmt', // use rustfmt to only check the files provided by lint-staged