feat: oneclient browser wip

This commit is contained in:
Szedann 2025-08-20 13:03:49 +02:00
parent fdf74258a0
commit 6f84f5d1cc
8 changed files with 388 additions and 15 deletions

View file

@ -165,7 +165,7 @@ export type SettingProfileModel = { name: string; java_id: number | null; res: R
export type Settings = { global_game_settings: SettingProfileModel; allow_parallel_running_clusters: boolean; enable_gamemode: boolean; discord_enabled: boolean; max_concurrent_requests: number; settings_version: number; native_window_frame: boolean }
export type SettingsOsExtra = { enable_gamemode: boolean | null }
export type SettingsOsExtra = Record<string, never>
export type Sort = "Relevance" | "Downloads" | "Newest" | "Updated"
@ -228,8 +228,15 @@ export type VersionType =
*/
"old_beta"
const ARGS_MAP = { 'oneclient':'{"openDevTools":[],"getClustersGroupedByMajor":[]}', 'core':'{"openMsaLogin":[],"readSettings":[],"getProfileOrDefault":["name"],"getLogByName":["id","name"],"getWorlds":["id"],"launchCluster":["id","uuid"],"getGameVersions":[],"getUser":["uuid"],"removeUser":["uuid"],"getUsersFromAuthor":["provider","author"],"getPackage":["provider","slug"],"setDefaultUser":["uuid"],"getMultiplePackages":["provider","slugs"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"updateClusterById":["id","request"],"getGlobalProfile":[],"getLoadersForVersion":["mc_version"],"getPackageBody":["provider","body"],"writeSettings":["setting"],"getScreenshots":["id"],"getLogs":["id"],"getDefaultUser":["fallback"],"getClusterById":["id"],"removeCluster":["id"],"updateClusterProfile":["name","profile"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"createCluster":["options"],"getClusters":[],"searchPackages":["provider","query"],"getUsers":[]}', 'events':'{"ingress":["event"],"process":["event"],"message":["event"]}', 'folders':'{"fromCluster":["folder_name"],"openCluster":["folder_name"]}' }
export type Router = { 'core': { getClusters: () => Promise<ClusterModel[]>,
const ARGS_MAP = { 'folders':'{"openCluster":["folder_name"],"fromCluster":["folder_name"]}', 'events':'{"ingress":["event"],"process":["event"],"message":["event"]}', 'oneclient':'{"getClustersGroupedByMajor":[],"openDevTools":[]}', 'core':'{"getWorlds":["id"],"launchCluster":["id","uuid"],"getDefaultUser":["fallback"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"getClusterById":["id"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"getProfileOrDefault":["name"],"updateClusterProfile":["name","profile"],"writeSettings":["setting"],"updateClusterById":["id","request"],"getMultiplePackages":["provider","slugs"],"removeCluster":["id"],"getUser":["uuid"],"getGlobalProfile":[],"openMsaLogin":[],"getPackageBody":["provider","body"],"getLogByName":["id","name"],"getLoadersForVersion":["mc_version"],"searchPackages":["provider","query"],"readSettings":[],"removeUser":["uuid"],"getClusters":[],"getGameVersions":[],"getUsers":[],"getPackage":["provider","slug"],"getUsersFromAuthor":["provider","author"],"getScreenshots":["id"],"getLogs":["id"],"setDefaultUser":["uuid"],"createCluster":["options"]}' }
export type Router = { '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> },
'oneclient': { openDevTools: () => Promise<void>,
getClustersGroupedByMajor: () => Promise<Partial<{ [key in number]: ClusterModel[] }>> },
'core': { getClusters: () => Promise<ClusterModel[]>,
getClusterById: (id: number) => Promise<ClusterModel | null>,
removeCluster: (id: number) => Promise<null>,
createCluster: (options: CreateCluster) => Promise<ClusterModel>,
@ -258,14 +265,7 @@ getPackageBody: (provider: Provider, body: ManagedPackageBody) => Promise<string
getMultiplePackages: (provider: Provider, slugs: string[]) => Promise<ManagedPackage[]>,
getPackageVersions: (provider: Provider, slug: string, mcVersion: string | null, loader: GameLoader | null, offset: number, limit: number) => Promise<Paginated<ManagedVersion>>,
downloadPackage: (provider: Provider, packageId: string, versionId: string, clusterId: number, skipCompatibility: boolean | null) => Promise<PackageModel>,
getUsersFromAuthor: (provider: Provider, author: PackageAuthor) => Promise<ManagedUser[]> },
'folders': { fromCluster: (folderName: string) => Promise<string>,
openCluster: (folderName: string) => Promise<null> },
'oneclient': { openDevTools: () => Promise<void>,
getClustersGroupedByMajor: () => Promise<Partial<{ [key in number]: ClusterModel[] }>> },
'events': { ingress: (event: IngressPayload) => Promise<void>,
message: (event: MessagePayload) => Promise<void>,
process: (event: ProcessPayload) => Promise<void> } };
getUsersFromAuthor: (provider: Provider, author: PackageAuthor) => Promise<ManagedUser[]> } };
export type { InferCommandOutput }

View file

@ -0,0 +1,98 @@
import type { Provider, SearchResult } from '@/bindings.gen';
// import { abbreviateNumber, LOADERS, upperFirst } from '@/utils';
// import { categoryNameFromId } from '@/utils/browser';
import { Show, Tooltip } from '@onelauncher/common/components';
import { Link } from '@tanstack/react-router';
import { Download01Icon } from '@untitled-theme/icons-react';
import { useMemo } from 'react';
import { Focusable } from 'react-aria-components';
function includes<T, TArray extends T>(list: { includes: (arg0: TArray) => boolean }, element: T): element is TArray {
return list.includes(element as unknown as TArray);
}
export function PackageGrid({ items, provider }: { items: Array<SearchResult>; provider: Provider }) {
return (
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5! gap-4">
{items.map(item => (
<PackageItem key={item.project_id} {...item} provider={provider} />
))}
</div>
);
}
export function PackageItem({ provider, ...item }: SearchResult & { provider: Provider }) {
// const categoryName = categoryNameFromId[item.package_type];
// if (!(categoryName in item.categories))
// throw new Error('invalid categories');
// const loaders = useMemo(() => getCategories(item.categories).filter(cat => includes(LOADERS, cat)), [item.categories]);
return (
<Link
className="h-full min-w-50 overflow-hidden rounded-lg bg-component-bg hover:bg-component-bg-hover grid grid-rows-[7rem_auto] max-h-74 min-h-74"
params={{ provider, id: item.project_id }}
tabIndex={0}
to="/app/browser/package/$provider/$id"
>
<div
className="relative flex items-center justify-center overflow-hidden w-full h-28"
>
<Show
fallback={(
<div
className="aspect-ratio-square rounded-md bg-border/05 w-2/5"
/>
)}
when={item.icon_url}
>
<img alt={`Icon for ${item.title}`} className="absolute z-0 max-w-none w-7/6 opacity-50 blur-xl" src={item.icon_url} />
<img
alt={`Icon for ${item.title}`}
className="relative z-1 aspect-ratio-square rounded-lg image-render-auto h-5/6"
src={item.icon_url}
/>
</Show>
{/* <Tooltip className="bg-component-bg-disabled" text={loaders.map(upperFirst).join(', ')}>
<Focusable>
<div className="flex flex-col rounded-full bg-[#11171C]/75 border-component-border/70 border p-1 absolute top-0 right-0 m-2">
{loaders.toSpliced(loaders.length > 3 ? 2 : 3).map(loader => <FlatLoaderIcon className="w-4 m-1" key={loader} loader={loader} />)}
{loaders.length > 3 && (
<div className="bg-component-bg/50 rounded-full w-6 h-6 flex items-center justify-center">
<span className="tracking-tight -ml-0.5 mt-0.5">
+
{loaders.length - 2}
</span>
</div>
)}
</div>
</Focusable>
</Tooltip> */}
</div>
<div className="flex flex-1 flex-col gap-2 p-3">
<div className="flex flex-col gap-2">
<h4 className="text-fg-primary font-medium line-height-normal">{item.title}</h4>
<p className="text-xs text-fg-secondary">
By
{' '}
<span className="text-fg-primary">{item.author}</span>
{' '}
on
{' '}
{provider}
</p>
</div>
<p className="max-h-22 flex-1 overflow-hidden text-sm text-fg-secondary line-height-snug">{item.description}</p>
{/* <div className="flex flex-row gap-4 text-xs">
<Show when={provider !== 'SkyClient'}>
<div className="flex flex-row items-center gap-2">
<Download01Icon className="h-4 w-4" />
{abbreviateNumber(item.downloads)}
</div>
</Show>
</div> */}
</div>
</Link>
);
}

View file

@ -0,0 +1,136 @@
import type { PackageType, Provider } from '@/bindings.gen';
import { browserCategories, categoryNameFromId } from '@/utils/browser';
import { Dropdown, Show, TextField } from '@onelauncher/common/components';
import { createFileRoute } from '@tanstack/react-router';
import { SearchMdIcon } from '@untitled-theme/icons-react';
import { useMemo } from 'react';
interface BrowserSearchRouteSearchParams {
query?: string;
packageType: PackageType;
categories: Array<string>;
}
export const Route = createFileRoute('/app/cluster/browser/')({
component: RouteComponent,
validateSearch: (search): BrowserSearchRouteSearchParams => {
return {
query: search.query as string,
packageType: search.packageType as PackageType,
categories: search.categories as Array<string>,
};
},
});
const PROVIDERS: Array<Provider> = ['Modrinth', 'CurseForge', 'SkyClient'];
function RouteComponent() {
return (
<div className="relative h-full flex flex-1 flex-col items-center gap-2">
<div className="h-full w-full flex flex-1 flex-col items-center gap-y-2">
<div className="grid grid-cols-[180px_auto_160px] w-full gap-x-6">
<div />
<BrowserToolbar />
<div />
</div>
<div className="grid grid-cols-[180px_auto_160px] w-full gap-x-6 pb-8">
<BrowserCategories />
<div className="h-full flex flex-col gap-y-4">
<div className="h-full flex-1">
</div>
</div>
{/* <BrowserSidebar /> */}
</div>
</div>
</div>
);
}
function BrowserCategories() {
const search = Route.useSearch();
const { categories, packageType } = search;
const navigate = Route.useNavigate();
const switchCategory = useMemo(() => (category: string) => {
const newCategories = categories.includes(category)
? categories.filter(element => element !== category)
: [...categories, category];
navigate({
search: {
...search,
categories: newCategories,
},
});
}, []);
return (
<div className="top-0 h-fit min-w-50">
{/* <div /> */}
<div className="flex flex-col gap-y-6 ml-16">
<Show when>
<div className="flex flex-col gap-y-1.5">
<h5 className="my-1 text-sm uppercase opacity-60 font-light">Categories</h5>
{browserCategories[categoryNameFromId[packageType]].map(category => (
<p
aria-selected={categories.includes(category)}
className="text-sm capitalize opacity-60 hover:opacity-90 text-fg-primary hover:text-fg-primary-hover aria-selected:opacity-100"
key={category}
onClick={() => switchCategory(category)}
>
{category}
</p>
))}
</div>
</Show>
</div>
</div>
);
}
function BrowserToolbar() {
const search = Route.useSearch();
const { provider, query } = search;
const navigate = Route.useNavigate();
const setQuery = (query: string) => {
navigate({ search: { ...search, query } });
};
return (
<div className="w-full flex flex-row justify-between bg-page">
<div className="flex flex-row gap-2">
<Dropdown
onSelectionChange={id => navigate({ search: { ...search, provider: id as Provider } })}
placeholder="Select a Provider"
selectedKey={provider}
>
{PROVIDERS.map(provider => (
<Dropdown.Item id={provider} key={provider}>
<div className="flex flex-row">
{/* <ProviderIcon className="size-4 mr-2 self-center" provider={provider} /> */}
{provider}
</div>
</Dropdown.Item>
))}
</Dropdown>
</div>
<div className="flex flex-row gap-2">
<TextField
className="min-w-64"
iconLeft={<SearchMdIcon className="scale-75" />}
// onChange={e => setQuery(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key !== 'Enter')
return;
e.preventDefault();
setQuery(e.currentTarget.value);
}}
placeholder="Search for content"
value={query}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,23 @@
import type { Provider } from '@/bindings.gen';
import { Dropdown, Show } from '@onelauncher/common/components';
import { createFileRoute, Outlet } from '@tanstack/react-router';
import { SearchMdIcon } from '@untitled-theme/icons-react';
export interface BrowserRouteSearchParams {
provider: Provider;
}
export const Route = createFileRoute('/app/cluster/browser')({
component: RouteComponent,
validateSearch: (search): BrowserRouteSearchParams => {
return {
provider: search.provider as Provider,
};
},
});
function RouteComponent() {
return (
<Outlet />
);
}

View file

@ -0,0 +1,117 @@
import type { PackageCategories, PackageModCategory, PackageType, Provider } from '@/bindings.gen';
export const BROWSER_VIEWS: Array<string> = ['grid', 'list'] as const;
type Key<T> = T extends Record<infer K, any> ? K : never;
const ModCategories = [
'Adventure',
'Library',
'Equipment',
'Patches',
'Cosmetic',
'Food',
'Magic',
'Information',
'Misc',
'Performance',
'Redstone',
'ServerUtil',
'Storage',
'Technology',
'Farming',
'Automation',
'Transport',
'Utility',
'QoL',
'WorldGen',
'Mobs',
'Economy',
'Social',
] as const;
const ModpackCategories = [
'Technology',
'Quests',
'Optimization',
'Multiplayer',
'Magic',
'LightWeight',
'Combat',
'Challenging',
'Adventure',
] as const;
const ResourcePackCategories = [
'X8',
'X16',
'X32',
'X48',
'X64',
'X128',
'X256',
'X512',
'VanillaLike',
'Utility',
'Tweaks',
'Themed',
'Simplistic',
'Realistic',
'Modded',
'Decoration',
'Cursed',
'Combat',
'Audio',
'Blocks',
'CoreShaders',
'Gui',
'Fonts',
'Equipment',
'Environment',
'Entities',
'Items',
'Locale',
'Models',
] as const;
const ShaderCategories = [
'VanillaLike',
'SemiRealistic',
'Realistic',
'Fantasy',
'Cursed',
'Cartoon',
'Bloom',
'Atmosphere',
'Reflections',
'Shadows',
'PBR',
'PathTracing',
'Foliage',
'ColoredLightning',
'Potato',
'Low',
'Medium',
'High',
'Ultra',
] as const;
export const browserCategories = {
Mod: ModCategories,
ResourcePack: ResourcePackCategories,
ModPack: ModpackCategories,
Shader: ShaderCategories,
DataPack: ModCategories,
} as const satisfies Record<Key<PackageCategories>, ReadonlyArray<string>>;
type CategoryIdMap = {
[K in keyof typeof browserCategories as Lowercase<K>]: K
};
export const categoryNameFromId: CategoryIdMap = {
mod: 'Mod',
resourcepack: 'ResourcePack',
modpack: 'ModPack',
shader: 'Shader',
datapack: 'DataPack',
};

View file

@ -1,7 +1,7 @@
import type { Paginated, SearchResult } from '@/bindings.gen';
import { PackageGrid } from '@/components/content/PackageItem';
import { useBrowserContext, useBrowserSearch } from '@/hooks/useBrowser';
import usePagination from '@/hooks/usePagination';
import { usePagination } from '@onelauncher/common';
import { createFileRoute } from '@tanstack/react-router';
import { useEffect, useRef, useState } from 'react';
import { BrowserLayout } from './route';

View file

@ -1 +1,2 @@
export * from './useCommand';
export * from './usePagination';

View file

@ -8,7 +8,7 @@ export interface PaginationOptions {
initialPage?: number;
};
function usePagination(options: PaginationOptions) {
export function usePagination(options: PaginationOptions) {
// const [options, setOptions] = useState(initialOptions);
const [page, setPage] = useState(Math.max(1, options.initialPage || 1));
@ -114,5 +114,3 @@ function usePagination(options: PaginationOptions) {
Navigation,
};
}
export default usePagination;