diff --git a/apps/frontend/src/assets/logos/skyclient.png b/apps/frontend/src/assets/logos/skyclient.png new file mode 100644 index 0000000..1879366 Binary files /dev/null and b/apps/frontend/src/assets/logos/skyclient.png differ diff --git a/apps/frontend/src/ui/components/content/ProviderIcon.tsx b/apps/frontend/src/ui/components/content/ProviderIcon.tsx index 0530b33..b15f857 100644 --- a/apps/frontend/src/ui/components/content/ProviderIcon.tsx +++ b/apps/frontend/src/ui/components/content/ProviderIcon.tsx @@ -1,6 +1,7 @@ import type { ManagedPackage, Providers } from '@onelauncher/client/bindings'; import CurseforgeIcon from '~assets/logos/curseforge.svg?component-solid'; import ModrinthImage from '~assets/logos/modrinth.svg?component-solid'; +import SkyClientImage from '~assets/logos/skyclient.png'; import { type Component, type JSX, Match, Show, splitProps, Switch } from 'solid-js'; export function getProviderLogoElement(provider: ManagedPackage | Providers): string | Component { @@ -10,6 +11,7 @@ export function getProviderLogoElement(provider: ManagedPackage | Providers): st const mapping: Record, string | Component> = { modrinth: ModrinthImage, curseforge: CurseforgeIcon, + skyclient: SkyClientImage, }; return mapping[providerName]; diff --git a/apps/frontend/src/ui/components/content/SearchResults.tsx b/apps/frontend/src/ui/components/content/SearchResults.tsx index a8c5417..2ec4eb4 100644 --- a/apps/frontend/src/ui/components/content/SearchResults.tsx +++ b/apps/frontend/src/ui/components/content/SearchResults.tsx @@ -160,16 +160,18 @@ function PackageItem(props: SearchResult & { provider: Providers; row?: boolean

{props.description}

-
- - {abbreviateNumber(props.downloads)} -
- - 0}> +
- - {abbreviateNumber(props.follows)} + + {abbreviateNumber(props.downloads)}
+ + 0}> +
+ + {abbreviateNumber(props.follows)} +
+
diff --git a/apps/frontend/src/ui/pages/browser/BrowserPackage.tsx b/apps/frontend/src/ui/pages/browser/BrowserPackage.tsx index 9f0a4ae..6e07fbf 100644 --- a/apps/frontend/src/ui/pages/browser/BrowserPackage.tsx +++ b/apps/frontend/src/ui/pages/browser/BrowserPackage.tsx @@ -137,8 +137,8 @@ export default BrowserPackage; function BrowserSidebar(props: { package: ManagedPackage }) { const [authors] = useCommand(() => props.package, () => bridge.commands.getProviderAuthors(props.package.provider, props.package.author)); - const createdAt = () => new Date(props.package.created); - const updatedAt = () => new Date(props.package.updated); + const createdAt = createMemo(() => props.package.created ? new Date(props.package.created) : null); + const updatedAt = createMemo(() => props.package.updated ? new Date(props.package.updated) : null); const promptOpen = usePromptOpener(); return ( @@ -166,16 +166,18 @@ function BrowserSidebar(props: { package: ManagedPackage }) {

{props.package.description}

-
- - {abbreviateNumber(props.package.downloads)} -
- - 0}> +
- - {abbreviateNumber(props.package.followers)} + + {abbreviateNumber(props.package.downloads)}
+ + 0}> +
+ + {abbreviateNumber(props.package.followers)} +
+
@@ -235,25 +237,29 @@ function BrowserSidebar(props: { package: ManagedPackage }) { - -
- - Created - - {formatAsRelative(createdAt().getTime(), 'en', 'long')} - -
-
+ + +
+ + Created + + {formatAsRelative(createdAt()!.getTime(), 'en', 'long')} + +
+
+
- -
- - Last Updated - - {formatAsRelative(updatedAt().getTime(), 'en', 'long')} - -
-
+ + +
+ + Last Updated + + {formatAsRelative(updatedAt()!.getTime(), 'en', 'long')} + +
+
+
diff --git a/apps/frontend/src/ui/pages/browser/BrowserRoot.tsx b/apps/frontend/src/ui/pages/browser/BrowserRoot.tsx index 58add3b..e03fc5a 100644 --- a/apps/frontend/src/ui/pages/browser/BrowserRoot.tsx +++ b/apps/frontend/src/ui/pages/browser/BrowserRoot.tsx @@ -7,7 +7,7 @@ import ProviderIcon from '~ui/components/content/ProviderIcon'; import useBrowser from '~ui/hooks/useBrowser'; import { PROVIDERS } from '~utils'; import { browserCategories } from '~utils/browser'; -import { For, type JSX, type ParentProps } from 'solid-js'; +import { createMemo, For, type JSX, type ParentProps, Show } from 'solid-js'; import BrowserMain from './BrowserMain'; import BrowserPackage from './BrowserPackage'; import BrowserSearch from './BrowserSearch'; @@ -87,23 +87,29 @@ function BrowserCategories() { browser.search(); }; + const categories = createMemo(() => { + return browserCategories.byPackageType(browser.packageType(), browser.searchQuery().provider); + }); + return (
-
-
Categories
- - {category => ( -

toggleCategory(category.id)} - > - {category.display} -

- )} -
-
+ 0}> +
+
Categories
+ + {category => ( +

toggleCategory(category.id)} + > + {category.display} +

+ )} +
+
+
); diff --git a/apps/frontend/src/utils/browser.ts b/apps/frontend/src/utils/browser.ts index af4adf2..db4fbda 100644 --- a/apps/frontend/src/utils/browser.ts +++ b/apps/frontend/src/utils/browser.ts @@ -93,4 +93,5 @@ const modMapping: Record = { { display: 'Utility', id: 'utility' }, { display: 'World Generation', id: 'worldgen' }, ], + SkyClient: [], }; diff --git a/apps/frontend/src/utils/index.ts b/apps/frontend/src/utils/index.ts index 186144d..f177513 100644 --- a/apps/frontend/src/utils/index.ts +++ b/apps/frontend/src/utils/index.ts @@ -174,13 +174,14 @@ export function getPackageUrl(pkg: ManagedPackage): string { return `https://www.curseforge.com/minecraft/${packageTypeMapping[pkg.package_type]}/${pkg.main}`; }, + SkyClient: () => 'TODO', }; return mapping[pkg.provider](); } export const LOADERS: Loader[] = ['vanilla', 'fabric', 'forge', 'neoforge', 'quilt'] as const; -export const PROVIDERS: Providers[] = ['Modrinth', 'Curseforge'] as const; +export const PROVIDERS: Providers[] = ['Modrinth', 'Curseforge', 'SkyClient'] as const; export const PACKAGE_TYPES: PackageType[] = ['mod', 'resourcepack', 'datapack', 'shaderpack'] as const; export const LAUNCHER_IMPORT_TYPES: ImportType[] = [ 'PrismLauncher', diff --git a/packages/client/src/bindings.ts b/packages/client/src/bindings.ts index c04e571..6ff12a3 100644 --- a/packages/client/src/bindings.ts +++ b/packages/client/src/bindings.ts @@ -857,7 +857,7 @@ export interface ManagedDependency { version_id: string | null; package_id: stri /** * Universal metadata for any managed package from a Mod distribution platform. */ -export interface ManagedPackage { provider: Providers; id: string; package_type: PackageType; title: string; description: string; body: PackageBody; main: string; versions: string[]; game_versions: string[]; loaders: Loader[]; icon_url: string | null; created: string; updated: string; client: PackageSide; server: PackageSide; downloads: bigint; followers: number; categories: string[]; optional_categories: string[] | null; license: License | null; author: Author; is_archived: boolean }; +export interface ManagedPackage { provider: Providers; id: string; package_type: PackageType; title: string; description: string; body: PackageBody; main: string; versions: string[]; game_versions: string[]; loaders: Loader[]; icon_url: string | null; created: string | null; updated: string | null; client: PackageSide; server: PackageSide; downloads: bigint; followers: number; categories: string[]; optional_categories: string[] | null; license: License | null; author: Author; is_archived: boolean }; /** * Universal metadata for any managed user from a Mod distribution platform. */ @@ -989,12 +989,12 @@ export interface ProviderSearchResults { provider: Providers; results: SearchRes /** * Providers for content packages */ -export type Providers = 'Modrinth' | 'Curseforge'; +export type Providers = 'Modrinth' | 'Curseforge' | 'SkyClient'; /** * Global Minecraft resolution. */ export type Resolution = [number, number]; -export interface SearchResult { slug: string; title: string; description: string; categories?: string[]; client_side: PackageSide; server_side: PackageSide; project_type: PackageType; downloads: bigint; icon_url?: string; project_id: string; author: string; display_categories?: string[]; versions: string[]; follows: number; date_created: string; date_modified: string }; +export interface SearchResult { slug: string; title: string; description: string; categories?: string[]; client_side: PackageSide; server_side: PackageSide; project_type: PackageType; downloads: bigint; icon_url?: string; project_id: string; author: string; versions: string[]; follows: number; date_created: string | null; date_modified: string | null }; /** * A global settings state for the launcher. */ diff --git a/packages/core/src/api/package/content/curseforge.rs b/packages/core/src/api/package/content/curseforge.rs index ceba583..cd55b21 100644 --- a/packages/core/src/api/package/content/curseforge.rs +++ b/packages/core/src/api/package/content/curseforge.rs @@ -146,8 +146,8 @@ impl From for ManagedPackage { game_versions: vec![], loaders: vec![], icon_url: package.logo.and_then(|l| Some(l.url)), - created: package.date_created, - updated: package.date_modified, + created: Some(package.date_created), + updated: Some(package.date_modified), client: crate::store::PackageSide::Unknown, server: crate::store::PackageSide::Unknown, downloads: package.download_count, @@ -193,11 +193,10 @@ impl Into for CurseforgePackage { downloads: self.download_count, icon_url: self.logo.map_or(String::new(), |l| l.url), categories: vec![], // TODO - display_categories: vec![], versions: vec![], follows: self.thumbs_up_count, - date_created: self.date_created, - date_modified: self.date_modified, + date_created: Some(self.date_created), + date_modified: Some(self.date_modified), } } } diff --git a/packages/core/src/api/package/content/mod.rs b/packages/core/src/api/package/content/mod.rs index 7f8ef5d..8b50d97 100644 --- a/packages/core/src/api/package/content/mod.rs +++ b/packages/core/src/api/package/content/mod.rs @@ -17,6 +17,7 @@ use crate::{Result, State}; mod curseforge; mod modrinth; +mod skyclient; /// Providers for content packages #[cfg_attr(feature = "specta", derive(specta::Type))] @@ -24,6 +25,7 @@ mod modrinth; pub enum Providers { Modrinth, Curseforge, + SkyClient, } impl std::fmt::Display for Providers { @@ -39,6 +41,7 @@ impl Providers { match self { Self::Modrinth => "Modrinth", Self::Curseforge => "Curseforge", + Self::SkyClient => "SkyClient", } } @@ -48,11 +51,12 @@ impl Providers { match self { Self::Modrinth => "https://modrinth.com", Self::Curseforge => "https://curseforge.com", + Self::SkyClient => "https://skyclient.co", } } pub const fn get_providers() -> &'static [Providers] { - &[Self::Modrinth, Self::Curseforge] + &[Self::Modrinth, Self::Curseforge, Self::SkyClient] } #[allow(clippy::too_many_arguments)] @@ -98,35 +102,37 @@ impl Providers { ) .await } + Self::SkyClient => { + skyclient::search( + query, + limit, + offset, + game_versions, + loaders + ).await + }, } } pub async fn get(&self, slug_or_id: &str) -> Result { Ok(match self { Self::Modrinth => modrinth::get(slug_or_id).await?.into(), - Self::Curseforge => curseforge::get( - slug_or_id - .parse::() - .map_err(|err| anyhow::anyhow!(err))?, - ) - .await? - .into(), + Self::Curseforge => curseforge::get(slug_or_id.parse::().map_err(|err| anyhow::anyhow!(err))?).await?.into(), + Self::SkyClient => skyclient::get(slug_or_id).await?.into(), }) } pub async fn get_multiple(&self, slug_or_ids: &[String]) -> Result> { - Ok(match self { - Self::Modrinth => { - if slug_or_ids.len() <= 0 { - return Ok(vec![]); - } + if slug_or_ids.len() <= 0 { + return Ok(vec![]); + } - modrinth::get_multiple(slug_or_ids) + Ok(match self { + Self::Modrinth => modrinth::get_multiple(slug_or_ids) .await? .into_iter() .map(Into::into) - .collect() - }, + .collect(), Self::Curseforge => { let parsed_ids = slug_or_ids .iter() @@ -145,6 +151,11 @@ impl Providers { .map(Into::into) .collect() }, + Self::SkyClient => skyclient::get_multiple(slug_or_ids) + .await? + .into_iter() + .map(Into::into) + .collect(), }) } @@ -166,6 +177,8 @@ impl Providers { let data = curseforge::get_all_versions(project_id, game_versions, loaders, page, page_size).await?; (data.0.into_iter().map(Into::into).collect(), data.1) } + + Self::SkyClient => todo!(), }) } @@ -181,6 +194,7 @@ impl Providers { .into_iter() .map(Into::into) .collect(), + Self::SkyClient => todo!(), }) } @@ -215,7 +229,8 @@ impl Providers { .await? .into_iter() .map(|(hash, version)| (hash, version.into())) - .collect() + .collect(), + Self::SkyClient => todo!(), }) } } diff --git a/packages/core/src/api/package/content/modrinth.rs b/packages/core/src/api/package/content/modrinth.rs index 67de79b..19595e0 100644 --- a/packages/core/src/api/package/content/modrinth.rs +++ b/packages/core/src/api/package/content/modrinth.rs @@ -75,8 +75,8 @@ impl From for ManagedPackage { game_versions: value.game_versions, loaders: value.loaders, icon_url: value.icon_url, - created: value.published, - updated: value.updated, + created: Some(value.published), + updated: Some(value.updated), client: value.client_side, server: value.server_side, downloads: value.downloads as u64, diff --git a/packages/core/src/api/package/content/skyclient.rs b/packages/core/src/api/package/content/skyclient.rs new file mode 100644 index 0000000..adb5422 --- /dev/null +++ b/packages/core/src/api/package/content/skyclient.rs @@ -0,0 +1,288 @@ +use std::sync::Arc; + +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use tokio::sync::{OnceCell, RwLock}; + +use crate::{data::{Loader, ManagedPackage, ManagedUser, ManagedVersion}, store::{ProviderSearchResults, SearchResult}, utils::{http, pagination::Pagination}, Result, State}; + +async fn fetch(url: &str) -> Result { + let state = State::get().await?; + Ok(serde_json::from_slice( + &http::fetch( + format!("{}/{}", crate::constants::SKYCLIENT_BASE_URL, url).as_str(), + None, + &state.fetch_semaphore + ).await? + )?) +} + +static SKYCLIENTSTORE_STATIC: OnceCell> = OnceCell::const_new(); + +struct SkyClientStore { + pub mods: Option> +} + +impl SkyClientStore { + pub async fn get() -> Result>> { + Ok(Arc::new( + SKYCLIENTSTORE_STATIC + .get_or_try_init(Self::initialize) + .await? + .read() + .await, + )) + } + + #[tracing::instrument] + #[onelauncher_macros::memory] + async fn initialize() -> Result> { + let mods: Vec = fetch("/mods/mods.json").await?; + + Ok(RwLock::new(SkyClientStore { + mods: Some(mods) + })) + } +} + + +#[derive(Default, Debug, Clone, Serialize, Deserialize)] +pub struct SkyClientMod { + pub id: String, + #[serde(default)] + pub nicknames: Vec, + pub display: String, + pub creator: String, + pub discord_code: Option, + pub description: Option, + pub icon: Option, + #[serde(default)] + pub links: Vec, + pub versions: Vec, + #[serde(default)] + pub commands: Vec, + pub icon_scaling: Option, + pub oneconfig: Option, +} + +impl Into for SkyClientMod { + fn into(self) -> SearchResult { + SearchResult { + slug: self.id.clone(), + project_id: self.id, + author: self.creator, + categories: vec![], + title: self.display, + client_side: crate::store::PackageSide::Required, + server_side: crate::store::PackageSide::Unknown, + date_created: None, + date_modified: None, + description: self.description.unwrap_or_default(), + project_type: crate::data::PackageType::Mod, + downloads: 0, + icon_url: self.icon.map(|i| format!("{}/icons/{}", crate::constants::SKYCLIENT_BASE_URL, i)).unwrap_or_default(), + versions: self.versions.iter().map(|v| v.version.clone()).collect(), + follows: 0, + } + } +} + +impl Into for SkyClientMod { + fn into(self) -> ManagedPackage { + ManagedPackage { + id: self.id.clone(), + main: self.id, + title: self.display, + body: crate::store::PackageBody::Markdown(self.description.clone().unwrap_or_default()), + categories: vec![], + client: crate::store::PackageSide::Required, + server: crate::store::PackageSide::Unknown, + created: None, + updated: None, + description: self.description.unwrap_or_default(), + downloads: 0, + followers: 0, + game_versions: self.versions.iter().flat_map(|v| v.game_versions.clone()).collect(), + icon_url: self.icon.map(|i| format!("{}/icons/{}", crate::constants::SKYCLIENT_BASE_URL, i)), + is_archived: false, + license: None, + loaders: self.versions.iter().flat_map(|v| v.loaders.clone().into_iter().map(Loader::from_string)).collect(), + versions: self.versions.iter().map(|v| v.version.clone()).collect(), + optional_categories: None, + author: crate::store::Author::Users(vec![ + ManagedUser { + id: self.creator.clone(), + username: self.creator, + is_organization_user: false, + avatar_url: None, + bio: None, + role: None, + url: self.discord_code.map(|code| format!("https://discord.gg/{}", code)), + } + ]), + package_type: crate::data::PackageType::Mod, + provider: super::Providers::SkyClient, + } + } +} + +#[derive(Default, Debug, Clone, Serialize, Deserialize)] +pub struct SkyClientModLink { + pub icon: String, + pub text: String, + pub link: String, +} + +#[derive(Default, Debug, Clone, Serialize, Deserialize)] +pub struct SkyClientModVersion { + pub version: String, + pub game_versions: Vec, + pub loaders: Vec, + pub file: String, + pub url: String, + pub hash: String, + pub mod_id: Option, +} + +pub async fn get(id: &str) -> Result { + let store = SkyClientStore::get().await?; + + let mods = match &store.mods { + Some(mods) => mods, + None => return Err(anyhow::anyhow!("mod not found").into()) + }; + + for m in mods { + if m.id == id { + return Ok(m.clone()); + } + } + + Err(anyhow::anyhow!("mod not found").into()) +} + +pub async fn get_multiple(slug_or_ids: &[String]) -> Result> { + let store = SkyClientStore::get().await?; + + let mods = match &store.mods { + Some(mods) => mods, + None => return Ok(vec![]) + }; + + let mut results = vec![]; + + for id in slug_or_ids { + for m in mods { + if m.id == *id { + results.push(m.clone()); + } + } + } + + Ok(results) +} + +// pub async fn get_all_versions( +// project_id: &str, +// game_versions: Option>, +// loaders: Option>, +// page: Option, +// page_size: Option, +// ) -> Result<(Vec, Pagination)> { +// let store = SkyClientStore::get().await?; + +// let mods = match &store.mods { +// Some(mods) => mods, +// None => return Ok((vec![], Pagination::default())) +// }; + + +// let mut versions = vec![]; + +// for m in mods { +// if m.id == project_id { +// for v in m.versions { +// let mut can_add = true; + +// if let Some(game_versions) = &game_versions { +// can_add = v.game_versions.iter().any(|gv| game_versions.contains(gv)) +// } + +// if can_add { +// if let Some(loaders) = &loaders { +// can_add = v.loaders.iter().any(|l| loaders.contains(&Loader::from_string(l))) +// } +// } + +// if can_add { +// +// } +// } +// } +// } +// } + +pub async fn search( + query: Option, + limit: Option, + offset: Option, + game_versions: Option>, + // package_types: Option>, + loaders: Option>, +) -> Result { + let store = SkyClientStore::get().await?; + + let mut results = ProviderSearchResults { + provider: super::Providers::SkyClient, + results: vec![], + total: 0, + }; + + let mods = match &store.mods { + Some(mods) => mods, + None => return Ok(results), + }; + + let query = query.map(|q| q.trim().to_lowercase()); + let limit = limit.map(|l| l as usize); + let offset = offset.unwrap_or(0); + + for m in mods { + let mut can_add = true; + + if let Some(query) = &query { + if !query.is_empty() { + let display = m.display.to_lowercase(); + + can_add = display.contains(query) || m.id.contains(query) || m.nicknames.contains(query) + } + } + + if can_add { + if let Some(game_versions) = &game_versions { + if !game_versions.is_empty() { + can_add = m.versions.iter().any(|v| v.game_versions.iter().any(|gv| game_versions.contains(&gv))) + } + } + } + + if can_add { + if let Some(loaders) = &loaders { + if !loaders.is_empty() { + can_add = m.versions.iter().any(|v| v.loaders.iter().any(|l| loaders.contains(&Loader::from_string(l.to_owned())))) + } + } + } + + if can_add { + if limit.map(|l| results.results.len() < l).unwrap_or(true) && offset <= results.total { + let result: SearchResult = m.clone().into(); + results.results.push(result); + } + + results.total += 1; + } + + } + + Ok(results) +} \ No newline at end of file diff --git a/packages/core/src/constants.rs b/packages/core/src/constants.rs index 69dde14..515cf5f 100644 --- a/packages/core/src/constants.rs +++ b/packages/core/src/constants.rs @@ -48,6 +48,8 @@ pub const METADATA_API_URL: &str = "https://meta.polyfrost.org"; pub const FEATURED_PACKAGES_URL: &str = "https://polyfrost.org/meta/onelauncher/featured.json"; /// / API base url. pub const MCLOGS_API_URL: &str = "https://api.mclo.gs/1"; +/// https://skyclient.co/ metadata base url. +pub const SKYCLIENT_BASE_URL: &str = "https://raw.githubusercontent.com/SkyblockClient/SkyblockClient-REPO/refs/heads/main/v1"; // =========== Paths =========== /// The public `settings.json` file used to store the global [`Settings`] state. diff --git a/packages/core/src/store/clusters.rs b/packages/core/src/store/clusters.rs index a87e0f8..9f6fb9d 100644 --- a/packages/core/src/store/clusters.rs +++ b/packages/core/src/store/clusters.rs @@ -376,7 +376,12 @@ impl Loader { } #[must_use] - pub fn from_string(val: &str) -> Self { + pub fn from_string(val: String) -> Self { + Self::from_str(val.as_str()) + } + + #[must_use] + pub fn from_str(val: &str) -> Self { match val { "forge" => Self::Forge, "fabric" => Self::Fabric, diff --git a/packages/core/src/store/package.rs b/packages/core/src/store/package.rs index 862f2b0..09f82f5 100644 --- a/packages/core/src/store/package.rs +++ b/packages/core/src/store/package.rs @@ -148,12 +148,10 @@ pub struct SearchResult { pub icon_url: String, pub project_id: String, pub author: String, - #[serde(default)] - pub display_categories: Vec, pub versions: Vec, pub follows: u32, - pub date_created: DateTime, - pub date_modified: DateTime, + pub date_created: Option>, + pub date_modified: Option>, } #[derive(Serialize, Deserialize, Clone, Debug)] @@ -719,8 +717,8 @@ pub struct ManagedPackage { pub loaders: Vec, pub icon_url: Option, - pub created: DateTime, - pub updated: DateTime, + pub created: Option>, + pub updated: Option>, pub client: PackageSide, pub server: PackageSide, pub downloads: u64,