feat: search results and queries

This commit is contained in:
Szedann 2025-07-16 23:18:15 +02:00
parent 3fa8e7f150
commit 292e7df7f8
4 changed files with 122 additions and 27 deletions

View file

@ -1,8 +1,9 @@
import type { ClusterModel, Provider, SearchQuery } from '@/bindings.gen';
import type { ClusterModel, 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';
export interface BrowserControllerType {
cluster: ClusterModel | undefined;
@ -52,6 +53,7 @@ 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);
}

View file

@ -1,8 +1,11 @@
import type { Provider, SearchResult } from '@/bindings.gen';
import OneConfigLogo from '@/assets/logos/oneconfig.svg';
import { useBrowserContext, useBrowserSearch } from '@/hooks/useBrowser';
import { abbreviateNumber } from '@/utils';
import { Button, Show } from '@onelauncher/common/components';
import { createFileRoute, useSearch } from '@tanstack/react-router';
import { ChevronRightIcon } from '@untitled-theme/icons-react';
import { ChevronRightIcon, Download01Icon, HeartIcon } from '@untitled-theme/icons-react';
import { useEffect } from 'react';
import { BrowserLayout } from './route';
export const Route = createFileRoute('/app/browser/')({
@ -14,6 +17,7 @@ function RouteComponent() {
<BrowserLayout>
<div className="flex flex-col gap-8">
<Featured />
<Search />
</div>
</BrowserLayout>
);
@ -21,9 +25,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 +53,90 @@ function Featured() {
</div>
</div>
</div>
{search.status}
{search.isSuccess && (
<>
{search.data.items.map(item => <p key={item.id}>{item.name}</p>)}
</>
)}
</div>
</Show>
);
}
function Search() {
const context = useBrowserContext();
const search = useBrowserSearch(context.provider, context.query);
useEffect(() => {
search.refetch();
}, [context.provider, context.query]);
return (
<div className="grid grid-cols-2 xl:grid-cols-3 gap-2">
<Show when={search.isSuccess}>
{search.data?.items.map(item => (
<PackageItem key={item.project_id} {...item} provider={context.provider} />
))}
</Show>
</div>
);
}
function PackageItem(props: SearchResult & { provider: Provider }) {
function redirect() {
}
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={props.icon_url}
>
<img alt={`Icon for ${props.title}`} className="absolute z-0 max-w-none w-7/6 opacity-50 filter-blur-xl" src={props.icon_url} />
<img
alt={`Icon for ${props.title}`}
className="relative z-1 aspect-ratio-square rounded-md image-render-auto w-2/5"
src={props.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">{props.title}</h4>
<p className="text-xs text-fg-secondary">
By
{' '}
<span className="text-fg-primary">{props.author}</span>
{' '}
on
{' '}
<span className="text-fg-primary">{props.provider}</span>
</p>
</div>
<p className="max-h-22 flex-1 overflow-hidden text-sm text-fg-secondary line-height-snug">{props.description}</p>
<div className="flex flex-row gap-4 text-xs">
<Show when={props.provider !== 'SkyClient'}>
<div className="flex flex-row items-center gap-2">
<Download01Icon className="h-4 w-4" />
{abbreviateNumber(props.downloads)}
</div>
{/* <Show when={props.follows > 0}>
<div className="flex flex-row items-center gap-2">
<HeartIcon className="h-4 w-4" />
{abbreviateNumber(props.follows)}
</div>
</Show> */}
</Show>
</div>
</div>
</div>
);
}

View file

@ -7,6 +7,7 @@ import { browserCategories } from '@/utils/browser';
import { Dropdown, Show, TextField } from '@onelauncher/common/components';
import { createFileRoute, Outlet } from '@tanstack/react-router';
import { SearchMdIcon } from '@untitled-theme/icons-react';
import { useEffect } from 'react';
export const Route = createFileRoute('/app/browser')({
component: RouteComponent,
@ -51,11 +52,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 +77,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 +94,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 +118,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,6 +137,7 @@ function BrowserCategories() {
}
function BrowserToolbar() {
const context = useBrowserContext();
return (
<div className="w-full flex flex-row justify-between bg-page">
<div className="flex flex-row gap-2" />
@ -138,6 +145,12 @@ function BrowserToolbar() {
<div className="flex flex-row gap-2">
<TextField
iconLeft={<SearchMdIcon />}
onKeyDown={(e) => {
if (e.key !== 'Enter')
return;
e.preventDefault();
context.setQuery({ ...context.query, query: e.currentTarget.value });
}}
placeholder="Search for content"
/>
</div>

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 {