Merge branch 'onelauncher/main' into 'oneclient/main'
This commit is contained in:
commit
46e8d87077
15 changed files with 677 additions and 47 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
118
apps/onelauncher/frontend/src/hooks/usePagination.tsx
Normal file
118
apps/onelauncher/frontend/src/hooks/usePagination.tsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
72
apps/onelauncher/frontend/src/routes/app/browser/search.tsx
Normal file
72
apps/onelauncher/frontend/src/routes/app/browser/search.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"baseUrl": ".",
|
||||
"module": "ESNext",
|
||||
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!'
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue