Merge branch 'onelauncher/main' into 'oneclient/main'

This commit is contained in:
LynithDev 2025-07-20 13:55:39 +02:00
commit 46e8d87077
15 changed files with 677 additions and 47 deletions

View file

@ -0,0 +1,75 @@
import type { Provider, SearchResult } from '@/bindings.gen';
import { abbreviateNumber } from '@/utils';
import { Show } from '@onelauncher/common/components';
import { useNavigate } from '@tanstack/react-router';
import { Download01Icon } from '@untitled-theme/icons-react';
export function PackageGrid({ items, provider }: { items: Array<SearchResult>; provider: Provider }) {
return (
<div className="grid grid-cols-2 xl:grid-cols-3 gap-2">
{items.map(item => (
<PackageItem key={item.project_id} {...item} provider={provider} />
))}
</div>
);
}
export function PackageItem({ provider, ...item }: SearchResult & { provider: Provider }) {
const navigate = useNavigate()
function redirect() {
navigate({to: "/app/browser/package/$provider/$slug", params: {provider, slug: item.slug}})
}
return (
<div
className="h-full min-w-50 flex overflow-hidden rounded-lg bg-component-bg hover:bg-component-bg-hover flex-col max-h-74 min-h-74"
onClick={redirect}
tabIndex={0}
>
<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 filter-blur-xl" src={item.icon_url} />
<img
alt={`Icon for ${item.title}`}
className="relative z-1 aspect-ratio-square rounded-md image-render-auto w-2/5"
src={item.icon_url}
/>
</Show>
</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
{' '}
<span className="text-fg-primary">{provider}</span>
</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>
</div>
);
}

View file

@ -1,8 +1,10 @@
import type { ClusterModel, Provider, SearchQuery } from '@/bindings.gen';
import type { ClusterModel, GameLoader, ManagedPackage, ManagedVersion, Paginated, Provider, SearchQuery, SearchResult } from '@/bindings.gen';
import type { UndefinedInitialDataOptions } from '@tanstack/react-query';
import { bindings } from '@/main';
import { PROVIDERS } from '@/utils';
import { useCommand } from '@onelauncher/common';
import { createContext, useContext, useMemo, useState } from 'react';
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
export interface BrowserControllerType {
cluster: ClusterModel | undefined;
@ -27,6 +29,7 @@ export function BrowserProvider(props: any) {
const { children } = props;
const [cluster, setCluster] = useState<ClusterModel | undefined>(undefined);
const [provider, setProvider] = useState<Provider>(PROVIDERS[0]);
const navigate = useNavigate()
const [query, setQuery] = useState<SearchQuery>({
filters: null,
query: null,
@ -35,6 +38,11 @@ export function BrowserProvider(props: any) {
sort: null,
});
useEffect(()=>{
if(query.filters?.categories || query.query)
navigate({to: "/app/browser/search"})
},[query])
const context = useMemo<BrowserControllerType>(() => ({
cluster,
setCluster,
@ -52,6 +60,15 @@ export function BrowserProvider(props: any) {
);
}
export function useBrowserSearch(provider: Provider, query: SearchQuery) {
return useCommand('searchPackages', () => bindings.core.searchPackages(provider, query));
export function useBrowserSearch(provider: Provider, query: SearchQuery, options?: Omit<UndefinedInitialDataOptions<Paginated<SearchResult>>, 'queryKey' | 'queryFn'> | undefined) {
const validFilters = useMemo(() => Object.values(query.filters ?? {}).filter(a => a).length > 0, [query.filters]);
return useCommand('searchPackages', () => bindings.core.searchPackages(provider, validFilters ? query : { ...query, filters: null }), options);
}
export function usePackageData(provider: Provider, slug: string, options?: Omit<UndefinedInitialDataOptions<ManagedPackage>, "queryKey" | "queryFn"> | undefined){
return useCommand("getPackage", () => bindings.core.getPackage(provider, slug), options)
}
export function usePackageVersions(provider: Provider, slug: string, {mc_versions, loaders, offset, limit, ...options}:{mc_versions?:Array<string>|null, loaders?:Array<GameLoader>|null, offset?:number, limit: number} & Omit<UndefinedInitialDataOptions<Paginated<ManagedVersion>>, "queryKey" | "queryFn">){
return useCommand("getPackageVersions", ()=>bindings.core.getPackageVersions(provider, slug, mc_versions??null, loaders??null, (offset??0) as unknown as bigint, limit as unknown as bigint), options)
}

View file

@ -0,0 +1,118 @@
import { Button, Show } from '@onelauncher/common/components';
import { ChevronRightIcon, ChevronLeftIcon } from '@untitled-theme/icons-react';
import { useState } from 'react';
export interface PaginationOptions {
itemsCount: number;
itemsPerPage: number;
initialPage?: number;
};
function usePagination(options: PaginationOptions) {
// const [options, setOptions] = useState(initialOptions);
const [page, setPage] = useState(Math.max(1, options.initialPage || 1));
const next = () => setPage(page => page + 1);
const prev = () => setPage(page => page - 1);
const totalPages = Math.ceil(options.itemsCount / options.itemsPerPage);
const hasNext = page < totalPages;
const hasPrev = page > 1;
const offset = (page - 1) * options.itemsPerPage;
// Exported
function reset(/*options: PaginationOptions*/) {
setPage(1);
}
function getVisiblePageNumbers(page: number) {
const pages: number[] = [];
const left = page - 1 > 1 ? page - 1 : null;
const right = page + 1 < totalPages ? page + 1 : null;
if (left !== null)
pages.push(left);
if (page !== 1 && page !== totalPages)
pages.push(page);
if (right !== null)
pages.push(right);
return pages;
}
function PaginationBtn(props: { page: number }) {
return (
<Button
className="p-2 text-lg min-w-8"
color={props.page === page ? 'primary' : 'secondary'}
onClick={() => {
if (props.page === page)
return;
setPage(props.page);
}}
>
<span className='text-trim'>{props.page}</span>
</Button>
);
}
const Navigation = () => (
<div className="flex flex-row items-center justify-center gap-x-1">
<Button
size='icon'
color='secondary'
children={<ChevronLeftIcon />}
isDisabled={page <= 1}
onClick={() => setPage(page => page - 1)}
/>
<PaginationBtn page={1} />
<Show when={page > 1 + 2}>
<span>...</span>
</Show>
{getVisiblePageNumbers(page).map(page=><PaginationBtn page={page} />)}
<Show when={page < totalPages - 2}>
<span>...</span>
</Show>
<Show when={totalPages > 1}>
<PaginationBtn page={totalPages} />
</Show>
<Button
size="icon"
color='secondary'
children={<ChevronRightIcon />}
isDisabled={page >= totalPages}
onClick={() => setPage(page => page + 1)}
/>
</div>
);
return {
page,
next,
prev,
hasNext,
hasPrev,
totalPages,
offset,
reset,
options,
Navigation,
};
}
export default usePagination;

View file

@ -1,12 +1,17 @@
import type { Provider } from '@/bindings.gen';
import OneConfigLogo from '@/assets/logos/oneconfig.svg';
import { PackageGrid } from '@/components/content/PackageItem';
import { useBrowserContext, useBrowserSearch } from '@/hooks/useBrowser';
import { PROVIDERS } from '@/utils';
import { Button, Show } from '@onelauncher/common/components';
import { createFileRoute, useSearch } from '@tanstack/react-router';
import { createFileRoute } from '@tanstack/react-router';
import { ChevronRightIcon } from '@untitled-theme/icons-react';
import { useState } from 'react';
import { BrowserLayout } from './route';
export const Route = createFileRoute('/app/browser/')({
component: RouteComponent,
component: RouteComponent
});
function RouteComponent() {
@ -14,6 +19,7 @@ function RouteComponent() {
<BrowserLayout>
<div className="flex flex-col gap-8">
<Featured />
<Lists />
</div>
</BrowserLayout>
);
@ -21,9 +27,8 @@ function RouteComponent() {
function Featured() {
const context = useBrowserContext();
const search = useBrowserSearch(context.provider, context.query);
return (
<Show when>
<Show when={!context.query.query && !(context.query.filters?.categories && context.query.filters.categories.length > 0)}>
<div className="flex flex-col gap-y-1">
<h5 className="ml-2">Featured</h5>
<div className="w-full flex flex-row overflow-hidden rounded-lg bg-page-elevated">
@ -50,13 +55,43 @@ function Featured() {
</div>
</div>
</div>
{search.status}
{search.isSuccess && (
<>
{search.data.items.map(item => <p key={item.id}>{item.name}</p>)}
</>
)}
</div>
</Show>
);
}
function Lists() {
return (
<div>
{PROVIDERS.map(provider => (
<List key={provider} provider={provider} />
))}
</div>
);
}
function List({ provider }: { provider: Provider }) {
const search = useBrowserSearch(provider, {
filters: null,
limit: 18 as unknown as bigint,
offset: null,
query: null,
sort: null,
});
const [expanded, setExpanded] = useState(false);
return (
<div>
<h3 className="uppercase p-3 opacity-60">{provider}</h3>
<div className={`relative overflow-hidden ${expanded ? '' : 'h-128'}`}>
{search.isSuccess
? <PackageGrid items={search.data.items} provider={provider} />
: <h3>Loading...</h3>}
<Show when={!expanded}>
<div className="absolute w-full bottom-0 flex justify-center p-10 left-0 right-0 z-10 bg-gradient-to-b from-transparent to-page to-20%">
<Button color="secondary" onClick={() => setExpanded(true)}>Show More</Button>
</div>
</Show>
</div>
</div>
);
}

View file

@ -0,0 +1,287 @@
import type { ClusterModel, ManagedPackage, ManagedUser, ManagedVersion, Paginated, Provider } from '@/bindings.gen'
import { useBrowserContext, usePackageData, usePackageVersions } from '@/hooks/useBrowser'
import { useClusters } from '@/hooks/useCluster'
import { bindings } from '@/main'
import { abbreviateNumber, formatAsRelative, PROVIDERS } from '@/utils'
import { useCommand } from '@onelauncher/common'
import { Show, Tooltip, Button, Dropdown } from '@onelauncher/common/components'
import { createFileRoute, Link } from '@tanstack/react-router'
import { openUrl } from '@tauri-apps/plugin-opener'
import { Download01Icon, LinkExternal01Icon, File02Icon, CalendarIcon, ClockRewindIcon, ChevronDownIcon, ChevronUpIcon } from '@untitled-theme/icons-react'
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { Collection, ListBox, ListBoxItem, Popover, Select } from 'react-aria-components'
export const Route = createFileRoute('/app/browser/package/$provider/$slug')({
component: RouteComponent,
})
function includes<T, C extends T>(list: { includes: (arg0: C) => boolean }, element: T): element is C {
return list.includes(element as unknown as C)
}
type PackageContextType = {
pkg: ManagedPackage | undefined
versions: Paginated<ManagedVersion> | undefined
}
const PackageContext = createContext<PackageContextType>({
pkg: undefined,
versions: undefined
})
function RouteComponent() {
const { provider, slug } = Route.useParams()
if (!includes(PROVIDERS, provider)) throw new Error("Invalid provider");
const packageData = usePackageData(provider, slug)
const browserContext = useBrowserContext()
const { data: versions } = usePackageVersions(provider, slug, {
mc_versions: browserContext.cluster ? [browserContext.cluster.mc_version] : null,
loaders: browserContext.cluster ? [browserContext.cluster.mc_loader] : null,
limit: 50
})
return (
<PackageContext value={{ pkg: packageData.data, versions }}>
<Show when={packageData.isSuccess && !packageData.isFetching}>
<div className="h-full flex flex-1 flex-row items-start gap-x-4">
<BrowserSidebar package={packageData.data!} />
<div className="min-h-full flex flex-1 flex-col items-start gap-y-4 pb-8">
<div className="flex flex-none flex-row gap-x-1 rounded-lg bg-component-bg p-1">
<Link to='/app/browser/package/$provider/$slug' params={{ provider, slug }}>About</Link>
<Link to='/app/browser/package/$provider/$slug' params={{ provider, slug }}>Versions</Link>
</div>
<div className="h-full min-h-full w-full flex-1">
</div>
</div>
</div>
</Show>
</PackageContext>
)
}
function BrowserSidebar({ package: pkg }: { package: ManagedPackage }) {
const { provider } = Route.useParams()
const createdAt = useMemo(() => pkg.created ? new Date(pkg.created) : null, []);
const updatedAt = useMemo(() => pkg.updated ? new Date(pkg.updated) : null, []);
const authors = useCommand("getUsersFromAuthor", () => bindings.core.getUsersFromAuthor(provider as Provider, pkg.author))
return (
<div className="sticky top-0 z-1 max-w-60 min-w-54 flex flex-col gap-y-4">
<div className="min-h-72 flex flex-col overflow-hidden rounded-lg bg-component-bg">
<div className="relative h-28 flex items-center justify-center overflow-hidden">
<img alt={`Icon for ${pkg.name}`} className="absolute z-0 max-w-none w-7/6 filter-blur-xl" src={pkg.icon_url || ''} />
<img alt={`Icon for ${pkg.name}`} className="relative z-1 aspect-ratio-square w-2/5 rounded-md image-render-auto" src={pkg.icon_url || ''} />
</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-snug">{pkg.name}</h4>
<p className="text-xs text-fg-secondary">
<span className="text-fg-primary capitalize">{pkg.package_type}</span>
{' '}
on
{' '}
<span className="text-fg-primary">{pkg.provider}</span>
</p>
</div>
<p className="max-h-22 flex-1 overflow-hidden text-sm text-fg-secondary line-height-snug">{pkg.short_desc}</p>
<div className="flex flex-row gap-4 text-xs">
<Show when={pkg.provider !== 'SkyClient'}>
<div className="flex flex-row items-center gap-2">
<Download01Icon className="h-4 w-4" />
{abbreviateNumber(pkg.downloads)}
</div>
</Show>
</div>
</div>
</div>
<InstallButton {...pkg} />
{/* <div className="flex flex-col gap-2 rounded-lg bg-component-bg p-3">
<h4 className="text-fg-primary font-bold">Links</h4>
<Link href={getPackageUrl(contentPackage)} includeIcon>
{contentPackage.provider}
{' '}
Page
</Link>
</div> */}
<div className="flex flex-col gap-2 rounded-lg bg-component-bg p-3">
<h4 className="text-fg-primary font-bold">Authors</h4>
{
authors.isSuccess
? authors.data.map(author => <Author author={author} />)
: <h3>Loading...</h3>
}
</div>
<div className="flex flex-col gap-2 rounded-lg bg-component-bg p-3 text-xs!">
<h4 className="text-fg-primary font-bold">Details</h4>
<Show when={pkg.license !== null}>
<div className="flex flex-row items-start gap-x-1">
<File02Icon className="h-3 min-w-3 w-3" />
License
<Link to={pkg.license?.url ?? "#"}>
{pkg.license?.name || pkg.license?.id || 'Unknown'}
</Link>
</div>
</Show>
<Show when={createdAt !== null}>
<Tooltip text={createdAt!.toLocaleString()}>
<div className="flex flex-row items-center gap-x-1">
<CalendarIcon className="h-3 min-w-3 w-3" />
Created
<span className="text-fg-primary font-medium">
{formatAsRelative(createdAt!.getTime(), 'en', 'long')}
</span>
</div>
</Tooltip>
</Show>
<Show when={updatedAt !== null}>
<Tooltip text={updatedAt!.toLocaleString()}>
<div className="flex flex-row items-center gap-x-1">
<ClockRewindIcon className="h-3 min-w-3 w-3" />
Last Updated
<span className="text-fg-primary font-medium">
{formatAsRelative(updatedAt!.getTime(), 'en', 'long')}
</span>
</div>
</Tooltip>
</Show>
</div>
</div>
);
}
function colorForType(type: string) {
switch (type.toLowerCase()) {
case 'release':
return 'bg-code-trace';
case 'snapshot':
return 'bg-code-debug';
case 'beta':
return 'bg-code-warn';
case 'alpha':
return 'bg-code-error';
default:
return 'bg-border/05';
}
}
function InstallButton({ ...pkg }: ManagedPackage) {
const triggerRef = useRef<HTMLDivElement>(null)
const { provider, slug } = Route.useParams()
const [open, setOpen] = useState(false)
const clusters = useClusters()
const browserContext = useBrowserContext()
const { versions } = useContext(PackageContext)
const version = useMemo(() => {
if (!versions || !browserContext.cluster) return undefined;
return versions.items.findLast(version =>
version.mc_versions.includes(browserContext.cluster!.mc_version)
&& version.loaders.includes(browserContext.cluster!.mc_loader)
)
}, [browserContext.cluster, versions])
function download() {
if (!version || !browserContext.cluster || !includes(PROVIDERS, provider)) return false;
downloadPackage(browserContext.cluster, provider, version)
}
return (
<div className="h-12 flex flex-row w-full relative">
<Button
color={version ? "primary" : "secondary"}
className="max-w-full flex-1 rounded-r-none!"
onClick={download}
isDisabled={!version}
>
<Download01Icon />
<div className='w-full text-sm'>
{
browserContext.cluster ?
version
? <span>Download latest to <br /><span className='text-md font-semibold'>{browserContext.cluster.name}</span></span>
: `No matching version found`
: "Select a Cluster"
}
</div>
</Button>
<Button className="w-8 h-full rounded-l-none border-l border-white/10" onClick={() => setOpen(true)}>
{open
? <ChevronUpIcon />
: <ChevronDownIcon />
}
</Button>
<Select onOpenChange={setOpen} isOpen={open}
aria-label='cluster'
onSelectionChange={e => {
const cluster = clusters?.find(cluster => cluster.id as unknown as number == e)
if (cluster) browserContext.setCluster(cluster)
}}>
<div className="w-full h-full absolute -z-10" ref={triggerRef}></div>
<Popover triggerRef={triggerRef} className="mt-1 rounded-lg shadow-md bg-component-bg border border-component-border -translate-x-full" style={{ width: `${triggerRef.current?.clientWidth}px` }}>
<ListBox className="outline-none flex flex-col gap-0.5">
<Collection items={clusters}>
{item => <ListBoxItem id={item.id as unknown as number}
className="group/item flex flex-row items-center justify-between gap-2 rounded-lg p-2 w-full text-dropdown-item-text hover:bg-component-bg-hover data-[focused]:bg-component-bg-pressed">{item.name}</ListBoxItem>}
</Collection>
</ListBox>
</Popover>
</Select>
</div>
)
}
function Author({ author }: { author: ManagedUser }) {
//TODO: onclick and fallback avatar url
return (
<a
className="flex flex-row items-center gap-x-1 rounded-md p-1 active:bg-component-bg-pressed hover:bg-component-bg-hover"
onClick={() => { if (author.url) openUrl(author.url) }}
>
<img alt={`${author.username}'s avatar`} className="h-8 min-h-8 min-w-8 w-8 rounded-[5px]" src={author.avatar_url || ""} />
<div className="flex flex-1 flex-col justify-center gap-y-1">
<span>{author.username}</span>
<Show when={author.is_organization_user}>
<span className="text-xs text-fg-secondary">Organization</span>
</Show>
<Show when={author.role !== null}>
<span className="text-xs text-fg-secondary">{author.role}</span>
</Show>
</div>
<LinkExternal01Icon className="h-4 w-4" />
</a>
)
}
function downloadPackage(cluster: ClusterModel, provider: Provider, version: ManagedVersion, skipCompatibility = false) {
return bindings.core.downloadPackage(provider, version.project_id, version.version_id, cluster.id, skipCompatibility)
}

View file

@ -4,9 +4,10 @@ import { BrowserProvider, useBrowserContext } from '@/hooks/useBrowser';
import { useClusters } from '@/hooks/useCluster';
import { PROVIDERS } from '@/utils';
import { browserCategories } from '@/utils/browser';
import { Dropdown, Show, TextField } from '@onelauncher/common/components';
import { AnimatedOutlet, Dropdown, Show, TextField } from '@onelauncher/common/components';
import { createFileRoute, Outlet } from '@tanstack/react-router';
import { SearchMdIcon } from '@untitled-theme/icons-react';
import { useEffect, useState } from 'react';
export const Route = createFileRoute('/app/browser')({
component: RouteComponent,
@ -16,7 +17,11 @@ function RouteComponent() {
return (
<BrowserProvider>
<div>
<Outlet />
<AnimatedOutlet
enter={{animate: {opacity: 1}, initial: {opacity: 1}}}
exit={{animate: {opacity: 0}, initial: {opacity: 0}}}
from={Route.id}
/>
</div>
</BrowserProvider>
);
@ -51,11 +56,16 @@ export function BrowserLayout(props: any) {
function BrowserSidebar() {
const context = useBrowserContext();
const clusters = useClusters();
useEffect(() => {
const game_versions = context.cluster?.mc_version ? [context.cluster.mc_version] : null;
const loaders = context.cluster?.mc_loader ? [context.cluster.mc_loader] : null;
context.setQuery({ ...context.query, filters: { ...defaultFilters, ...context.query.filters, game_versions, loaders } });
}, [context.cluster]);
return (
<div className="flex flex-col gap-y-4">
<div className="flex flex-col gap-y-4">
<div className="flex flex-col gap-y-1">
<h6 className="my-1">Active Cluster</h6>
<h6 className="my-1 uppercase opacity-60">Active Cluster</h6>
<Dropdown
onSelectionChange={(id) => {
const cluster = clusters?.find(cluster => cluster.id.toString() === id);
@ -71,7 +81,7 @@ function BrowserSidebar() {
</Dropdown>
</div>
<div className="flex flex-col gap-y-1">
<h6 className="my-1">Provider</h6>
<h6 className="my-1 uppercase opacity-60">Provider</h6>
<Dropdown onSelectionChange={id => context.setProvider(id as typeof context.provider)} selectedKey={context.provider}>
{PROVIDERS.map(provider => (
<Dropdown.Item id={provider} key={provider}>
@ -88,22 +98,22 @@ function BrowserSidebar() {
);
}
const defaultFilters:Filters = {
const defaultFilters: Filters = {
categories: null,
game_versions: null,
loaders: null,
package_types: null
}
package_types: null,
};
function BrowserCategories() {
const context = useBrowserContext();
const categories = browserCategories.byPackageType('mod', context.provider);
function switchCategory(category:string){
if(context.query.filters?.categories?.includes(category))
context.setQuery({...context.query, filters: {...defaultFilters, ...context.query.filters, categories: (context.query.filters?.categories ?? []).filter(cat=>cat!==category)}})
else
context.setQuery({...context.query, filters: {...defaultFilters, ...context.query.filters, categories: [...(context.query.filters?.categories ?? []), category]}})
function switchCategory(category: string) {
const newCategories = context.query.filters?.categories?.includes(category)
? context.query.filters.categories.filter(cat => cat !== category)
: [...(context.query.filters?.categories ?? []), category];
context.setQuery({ ...context.query, filters: { ...defaultFilters, ...context.query.filters, categories: newCategories.length > 0 ? newCategories : null } });
}
return (
@ -112,13 +122,13 @@ function BrowserCategories() {
<div className="flex flex-col gap-y-6">
<Show when>
<div className="flex flex-col gap-y-2">
<h6 className="my-1 uppercase">Categories</h6>
<h6 className="my-1 uppercase opacity-60">Categories</h6>
{categories.map(category => (
<p
aria-selected={context.query.filters?.categories?.includes(category.id)}
className="text-md capitalize opacity-60 hover:opacity-90 text-fg-primary hover:text-fg-primary-hover aria-selected:opacity-100"
key={category.id}
onClick={()=>switchCategory(category.id)}
onClick={() => switchCategory(category.id)}
>
{category.display}
</p>
@ -131,13 +141,23 @@ function BrowserCategories() {
}
function BrowserToolbar() {
const context = useBrowserContext();
const [query, setQuery] = useState(context.query.query ?? "")
return (
<div className="w-full flex flex-row justify-between bg-page">
<div className="flex flex-row gap-2" />
<div className="flex flex-row gap-2">
<TextField
value={query}
iconLeft={<SearchMdIcon />}
onChange={e=>setQuery(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key !== 'Enter')
return;
e.preventDefault();
context.setQuery({ ...context.query, query: e.currentTarget.value });
}}
placeholder="Search for content"
/>
</div>

View file

@ -0,0 +1,72 @@
import { PackageGrid } from '@/components/content/PackageItem';
import { useBrowserContext, useBrowserSearch } from '@/hooks/useBrowser';
import { Show } from '@onelauncher/common/components';
import { createFileRoute } from '@tanstack/react-router';
import { useEffect, useMemo, useState } from 'react';
import { BrowserLayout } from './route';
import type { Paginated, SearchResult } from '@/bindings.gen';
import type { PaginationOptions } from '@/hooks/usePagination';
import usePagination from '@/hooks/usePagination';
export const Route = createFileRoute('/app/browser/search')({
component: RouteComponent,
});
function RouteComponent() {
return (
<BrowserLayout>
<div className="flex flex-col gap-8">
<Search />
</div>
</BrowserLayout>
);
}
function Search() {
const context = useBrowserContext();
const search = useBrowserSearch(context.provider, context.query, {
});
useEffect(() => {
search.refetch();
}, [context.provider, context.query, context.cluster]);
return (
<>
{
search.data
? <Results results={search.data}/>
: <h1>Loading</h1>
}
</>
);
}
function Results({results}:{results:Paginated<SearchResult>}){
const context = useBrowserContext();
const [oldTotal, setOldTotal] = useState(results.total)
const pagination = usePagination({
itemsCount: results.total as unknown as number,
itemsPerPage: context.query.limit as unknown as number
})
useEffect(()=>{
context.setQuery({...context.query, offset: pagination.offset as unknown as bigint})
},[pagination.page])
useEffect(()=>{
if(oldTotal === results.total) return;
pagination.reset()
console.log("pagination reset", results.total)
setOldTotal(results.total)
},[results.total])
return (
<div>
<div className='w-full flex justify-end my-2'>
<pagination.Navigation/>
</div>
{results.items.length == 0
? <h2 className='text-center text-lg font-light italic opacity-60'>No results</h2>
: <PackageGrid items={results.items} provider={context.provider}/>
}
</div>
)
}

View file

@ -41,3 +41,7 @@ code {
background: linear-gradient(270deg, transparent, rgba(0, 0, 0, 0.85) 80%);
}
@utility text-trim{
text-box: trim-both cap alphabetic
}

View file

@ -6,7 +6,7 @@ export interface CategoryItem {
}
export const browserCategories = {
byPackageType(packageType: 'mod' | string, provider: string): Array<CategoryItem> {
byPackageType(packageType: 'mod' | string & {}, provider: string): Array<CategoryItem> {
switch (packageType) {
case 'mod':
return this.mod(provider);

View file

@ -3,7 +3,7 @@
"compilerOptions": {
"target": "ES2022",
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"baseUrl": ".",
"module": "ESNext",

View file

@ -201,14 +201,14 @@ pub struct PackageLinks {
#[onelauncher_macro::specta]
#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PackageDonationUrl {
#[serde(alias = "id")]
pub platform: PackageDonationPlatform,
pub id: PackageDonationPlatform,
pub url: String,
}
/// https://api.modrinth.com/v2/tag/donation_platform
#[onelauncher_macro::specta]
#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PackageDonationPlatform {
Patreon,
#[serde(alias = "bmac")]

View file

@ -70,11 +70,11 @@ impl ProviderExt for ModrinthProviderImpl {
if let Some(loaders) = &filters.loaders {
for loader in loaders {
builder.and(Facet("loaders".to_string(), FacetOperation::Eq, loader.to_string()));
builder.and(Facet("categories".to_string(), FacetOperation::Eq, loader.to_string()));
}
}
params.append_pair("facets", &builder.build());
params.append_pair("facets", &format!("[{}]", &builder.build()));
}
if let Some(sort) = &query.sort {
@ -510,8 +510,8 @@ impl From<TeamMember> for ManagedUser {
struct ModrinthUser {
pub id: String,
pub username: String,
pub avatar_url: String,
pub bio: String,
pub avatar_url: Option<String>,
pub bio: Option<String>,
}
impl From<ModrinthUser> for ManagedUser {
@ -520,16 +520,12 @@ impl From<ModrinthUser> for ManagedUser {
id: value.id.clone(),
username: value.username,
url: Some(format!(
"{}/user/{}",
"{}user/{}",
Provider::Modrinth.website(),
value.id
)),
avatar_url: Some(value.avatar_url),
bio: if value.bio.is_empty() || value.bio.eq_ignore_ascii_case("a modrinth creator.") {
None
} else {
Some(value.bio)
},
avatar_url: value.avatar_url,
bio: value.bio,
is_organization_user: false,
role: None,
}

View file

@ -1,7 +1,7 @@
use interpulse::api::minecraft::Version;
use onelauncher_entity::{icon::Icon, loader::GameLoader, package::Provider, packages, resolution::Resolution};
use sea_orm::ActiveValue::Set;
use crate::{api::packages::{data::{SearchResult, ManagedPackage, ManagedUser, ManagedVersion, SearchQuery}, provider::ProviderExt}, store::{Settings, State}, utils::pagination::Paginated};
use crate::{api::packages::{data::{ManagedPackage, ManagedUser, ManagedVersion, PackageAuthor, SearchQuery, SearchResult}, provider::ProviderExt}, store::{Settings, State}, utils::pagination::Paginated};
use tauri::{AppHandle, Runtime};
use crate::{api::{self, cluster::dao::ClusterId}, error::{LauncherError, LauncherResult}, store::{credentials::MinecraftCredentials, Core}};
@ -99,6 +99,9 @@ pub trait TauriLauncherApi {
#[taurpc(alias = "downloadPackage")]
async fn download_package(provider: Provider, package_id: String, version_id: String, cluster_id: ClusterId, skip_compatibility: Option<bool>) -> LauncherResult<packages::Model>;
#[taurpc(alias = "getUsersFromAuthor")]
async fn get_users_from_author(provider: Provider, author: PackageAuthor) -> LauncherResult<Vec<ManagedUser>>;
}
@ -425,4 +428,8 @@ impl TauriLauncherApi for TauriLauncherApiImpl {
Ok(model)
}
async fn get_users_from_author(self, provider: Provider, author: PackageAuthor) -> LauncherResult<Vec<ManagedUser>> {
Ok(provider.get_users_from_author(author).await?)
}
}

View file

@ -198,8 +198,7 @@ https://learn.microsoft.com/windows/package-manager/winget/
Write-Host
Write-Host 'Installing Rust tools...' -ForegroundColor Yellow
cargo install cargo-watch
cargo install sqlx-cli
if (Get-Command cargo-binstall -ea 0) { cargo binstall cargo-watch sqlx-cli } else { cargo install cargo-watch sqlx-cli }
if ($LASTEXITCODE -ne 0) {
Exit-WithError 'Failed to install Rust tools'
}

View file

@ -184,7 +184,7 @@ esac
if [ "${CI:-}" != "true" ]; then
echo "installing rust tools..."
_tools="cargo-watch sqlx-cli"
echo "$_tools" | xargs cargo install
echo "$_tools" | xargs cargo $(has cargo-binstall && echo binstall || echo install)
fi
echo 'your machine has been setup for onelauncher development!'