feat: search pagination

This commit is contained in:
Szedann 2025-07-20 13:26:31 +02:00
parent 8edce35f76
commit 5e9b93bea2
4 changed files with 231 additions and 71 deletions

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

@ -13,16 +13,16 @@ import { Collection, ListBox, ListBoxItem, Popover, Select } from 'react-aria-co
export const Route = createFileRoute('/app/browser/package/$provider/$slug')({
component: RouteComponent,
component: RouteComponent,
})
function includes<T, C extends T>(list:{includes: (arg0:C)=>boolean}, element:T): element is C{
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
pkg: ManagedPackage | undefined
versions: Paginated<ManagedVersion> | undefined
}
const PackageContext = createContext<PackageContextType>({
@ -31,25 +31,26 @@ const PackageContext = createContext<PackageContextType>({
})
function RouteComponent() {
const {provider, slug} = Route.useParams()
if(!includes(PROVIDERS, provider)) throw new Error("Invalid provider");
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})
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}}>
<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>
<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>
@ -65,13 +66,13 @@ function RouteComponent() {
}
function BrowserSidebar({package: pkg}: { package: ManagedPackage }) {
const {provider} = Route.useParams()
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))
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">
@ -120,8 +121,8 @@ function BrowserSidebar({package: pkg}: { package: ManagedPackage }) {
<h4 className="text-fg-primary font-bold">Authors</h4>
{
authors.isSuccess
? authors.data.map(author=><Author author={author}/>)
: <h3>Loading...</h3>
? authors.data.map(author => <Author author={author} />)
: <h3>Loading...</h3>
}
</div>
@ -183,100 +184,104 @@ function colorForType(type: string) {
}
}
function InstallButton({...pkg}:ManagedPackage){
const triggerRef = useRef<HTMLButtonElement>(null)
const elementRef = useRef<HTMLDivElement>(null)
const {provider, slug} = Route.useParams()
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=>
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])
}, [browserContext.cluster, versions])
function download(){
if(!version || !browserContext.cluster || !includes(PROVIDERS, provider)) return false;
function download() {
if (!version || !browserContext.cluster || !includes(PROVIDERS, provider)) return false;
downloadPackage(browserContext.cluster, provider, version)
}
return (
<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="h-12 flex flex-row w-full" ref={elementRef}>
<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/>
<Download01Icon />
<div className='w-full text-sm'>
{
browserContext.cluster ?
version
? <span>Download to <br /><span className='text-md font-semibold'>{browserContext.cluster.name}</span></span>
? <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 rounded-l-none border-l border-white/10" onClick={()=>setOpen(!open)} ref={triggerRef}>
<Button className="w-8 h-full rounded-l-none border-l border-white/10" onClick={() => setOpen(true)}>
{open
? <ChevronUpIcon/>
: <ChevronDownIcon/>
? <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>
<Popover triggerRef={triggerRef} className="mt-1 rounded-lg shadow-md bg-component-bg border border-component-border" style={{width: `${elementRef.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">{item.name}</ListBoxItem>}
</Collection>
</ListBox>
</Popover>
</Select>
)
}
function Author({author}:{author:ManagedUser}){
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>
<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.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>
<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){
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

@ -2,8 +2,11 @@ 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 } from 'react';
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,
@ -31,9 +34,39 @@ function Search() {
<>
{
search.data
? <PackageGrid items={search.data.items} provider={context.provider} />
? <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

@ -29,4 +29,8 @@
mask: linear-gradient(270deg, transparent, black 50%);
background: linear-gradient(270deg, transparent, rgba(0, 0, 0, 0.85) 80%);
}
}
@utility text-trim{
text-box: trim-both cap alphabetic
}