feat: search pagination
This commit is contained in:
parent
8edce35f76
commit
5e9b93bea2
4 changed files with 231 additions and 71 deletions
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;
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue