feat: featured section WIP

This commit is contained in:
Szedann 2025-07-20 16:51:07 +02:00
parent f998722d71
commit 1703318703
3 changed files with 116 additions and 116 deletions

View file

@ -3,8 +3,8 @@ import type { UndefinedInitialDataOptions } from '@tanstack/react-query';
import { bindings } from '@/main';
import { PROVIDERS } from '@/utils';
import { useCommand } from '@onelauncher/common';
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
export interface BrowserControllerType {
cluster: ClusterModel | undefined;
@ -29,7 +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 navigate = useNavigate();
const [query, setQuery] = useState<SearchQuery>({
filters: null,
query: null,
@ -38,10 +38,10 @@ export function BrowserProvider(props: any) {
sort: null,
});
useEffect(()=>{
if(query.filters?.categories || query.query)
navigate({to: "/app/browser/search"})
},[query])
useEffect(() => {
if (query.filters?.categories || query.query)
navigate({ to: '/app/browser/search' });
}, [query]);
const context = useMemo<BrowserControllerType>(() => ({
cluster,
@ -65,10 +65,10 @@ export function useBrowserSearch(provider: Provider, query: SearchQuery, options
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 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)
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

@ -1,16 +1,16 @@
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 { useBrowserSearch, usePackageData } from '@/hooks/useBrowser';
import { PROVIDERS } from '@/utils';
import { Button, Show } from '@onelauncher/common/components';
import { createFileRoute } from '@tanstack/react-router';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { ChevronRightIcon } from '@untitled-theme/icons-react';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { BrowserLayout } from './route';
export const Route = createFileRoute('/app/browser/')({
component: RouteComponent
component: RouteComponent,
});
@ -26,17 +26,21 @@ function RouteComponent() {
}
function Featured() {
const context = useBrowserContext();
// const context = useBrowserContext();
const [provider, slug] = useMemo<[Provider, string]>(() => ['Modrinth', 'sodium'], []);
const featuredPackage = usePackageData(provider, slug);
const navigate = useNavigate();
return (
<Show when={!context.query.query && !(context.query.filters?.categories && context.query.filters.categories.length > 0)}>
<Show when={featuredPackage.isSuccess}>
<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">
<div className="w-full p-1">
<img alt="thumbnail" className="aspect-ratio-video h-full rounded-md object-cover object-center" src="https://cdn.modrinth.com/data/AANobbMI/295862f4724dc3f78df3447ad6072b2dcd3ef0c9_96.webp" />
<img alt="thumbnail" className="aspect-ratio-video h-full rounded-md object-cover object-center" src={featuredPackage.data?.icon_url ?? ''} />
</div>
<div className="max-w-84 min-w-52 flex flex-col gap-y-1 p-4">
<h2>Sodium</h2>
<h2>{featuredPackage.data?.name}</h2>
<Show when={false}>
<div className="w-fit flex flex-row items-center gap-x-1 rounded-lg bg-border/10 px-1.5 py-1 text-fg-primary transition hover:opacity-80">
@ -45,10 +49,10 @@ function Featured() {
</div>
</Show>
<p className="mt-1 flex-1 leading-normal">Peak mod</p>
<p className="mt-1 flex-1 leading-normal">{featuredPackage.data?.short_desc}</p>
<div className="flex flex-row justify-end">
<Button color="ghost">
<Button color="ghost" onClick={() => navigate({ to: '/app/browser/package/$provider/$slug', params: { provider, slug } })}>
View Package
<ChevronRightIcon />
</Button>

View file

@ -1,45 +1,45 @@
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'
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 { Button, Show, Tooltip } from '@onelauncher/common/components';
import { createFileRoute, Link } from '@tanstack/react-router';
import { openUrl } from '@tauri-apps/plugin-opener';
import { CalendarIcon, ChevronDownIcon, ChevronUpIcon, ClockRewindIcon, Download01Icon, File02Icon, LinkExternal01Icon } from '@untitled-theme/icons-react';
import { createContext, useContext, 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)
function includes<T, TArray extends T>(list: { includes: (arg0: TArray) => boolean }, element: T): element is TArray {
return list.includes(element as unknown as TArray);
}
type PackageContextType = {
pkg: ManagedPackage | undefined
versions: Paginated<ManagedVersion> | undefined
interface PackageContextType {
pkg: ManagedPackage | undefined;
versions: Paginated<ManagedVersion> | undefined;
}
const PackageContext = createContext<PackageContextType>({
pkg: undefined,
versions: 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 { 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
})
limit: 50,
});
return (
<PackageContext value={{ pkg: packageData.data, versions }}>
@ -49,9 +49,8 @@ function RouteComponent() {
<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 params={{ provider, slug }} to="/app/browser/package/$provider/$slug">About</Link>
<Link params={{ provider, slug }} to="/app/browser/package/$provider/$slug">Versions</Link>
</div>
@ -62,17 +61,16 @@ function RouteComponent() {
</div>
</Show>
</PackageContext>
)
);
}
function BrowserSidebar({ package: pkg }: { package: ManagedPackage }) {
const { provider } = Route.useParams()
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">
@ -106,7 +104,7 @@ function BrowserSidebar({ package: pkg }: { package: ManagedPackage }) {
</div>
</div>
<InstallButton {...pkg} />
<InstallButton />
{/* <div className="flex flex-col gap-2 rounded-lg bg-component-bg p-3">
<h4 className="text-fg-primary font-bold">Links</h4>
@ -121,7 +119,7 @@ function BrowserSidebar({ package: pkg }: { package: ManagedPackage }) {
<h4 className="text-fg-primary font-bold">Authors</h4>
{
authors.isSuccess
? authors.data.map(author => <Author author={author} />)
? authors.data.map(author => <Author author={author} key={author.id} />)
: <h3>Loading...</h3>
}
@ -133,7 +131,7 @@ function BrowserSidebar({ package: pkg }: { package: ManagedPackage }) {
<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 ?? "#"}>
<Link to={pkg.license?.url ?? '#'}>
{pkg.license?.name || pkg.license?.id || 'Unknown'}
</Link>
</div>
@ -168,60 +166,50 @@ function BrowserSidebar({ package: pkg }: { package: ManagedPackage }) {
);
}
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)
function InstallButton() {
const triggerRef = useRef<HTMLDivElement>(null);
const { provider } = 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;
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])
&& 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)
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}
className="max-w-full flex-1 rounded-r-none disabled:text-white/50 disabled:bg-blue-900"
color="primary"
isDisabled={!version}
onClick={download}
>
<Download01Icon />
<div className='w-full text-sm'>
<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>
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"
: 'Select a Cluster'
}
</div>
</Button>
@ -229,42 +217,50 @@ function InstallButton({ ...pkg }: ManagedPackage) {
<Button className="w-8 h-full rounded-l-none border-l border-white/10" onClick={() => setOpen(true)}>
{open
? <ChevronUpIcon />
: <ChevronDownIcon />
}
: <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>
<Select
aria-label="cluster" isOpen={open}
onOpenChange={setOpen}
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` }}>
<Popover className="mt-1 rounded-lg shadow-md bg-component-bg border border-component-border -translate-x-full" style={{ width: `${triggerRef.current?.clientWidth}px` }} triggerRef={triggerRef}>
<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>}
{item => (
<ListBoxItem
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"
id={item.id as unknown as number}
>
{item.name}
</ListBoxItem>
)}
</Collection>
</ListBox>
</Popover>
</Select>
</div>
)
);
}
function Author({ author }: { author: ManagedUser }) {
//TODO: onclick and fallback avatar url
// 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) }}
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 || ""} />
<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>
@ -279,9 +275,9 @@ function Author({ author }: { author: ManagedUser }) {
<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)
return bindings.core.downloadPackage(provider, version.project_id, version.version_id, cluster.id, skipCompatibility);
}