incredible art update
This commit is contained in:
parent
8d1b69d7a0
commit
ca4cea2ffb
13 changed files with 281 additions and 44 deletions
|
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
50
apps/oneclient/frontend/src/hooks/useCachedImage.ts
Normal file
50
apps/oneclient/frontend/src/hooks/useCachedImage.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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%)',
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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')}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue