Skip to content

Commit ac7bef4

Browse files
committed
feat: package page
1 parent a3ab0d8 commit ac7bef4

File tree

2 files changed

+285
-0
lines changed

2 files changed

+285
-0
lines changed

apps/onelauncher/frontend/src/components/content/PackageItem.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Provider, SearchResult } from '@/bindings.gen';
22
import { abbreviateNumber } from '@/utils';
33
import { Show } from '@onelauncher/common/components';
4+
import { useNavigate } from '@tanstack/react-router';
45
import { Download01Icon } from '@untitled-theme/icons-react';
56

67
export function PackageGrid({ items, provider }: { items: Array<SearchResult>; provider: Provider }) {
@@ -14,7 +15,9 @@ export function PackageGrid({ items, provider }: { items: Array<SearchResult>; p
1415
}
1516

1617
export function PackageItem({ provider, ...item }: SearchResult & { provider: Provider }) {
18+
const navigate = useNavigate()
1719
function redirect() {
20+
navigate({to: "/app/browser/package/$provider/$slug", params: {provider, slug: item.slug}})
1821
}
1922

2023
return (
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
import type { ClusterModel, ManagedPackage, ManagedUser, ManagedVersion, Paginated, Provider } from '@/bindings.gen'
2+
import { useBrowserContext, usePackageData, usePackageVersions } from '@/hooks/useBrowser'
3+
import { useClusters } from '@/hooks/useCluster'
4+
import { bindings } from '@/main'
5+
import { abbreviateNumber, formatAsRelative, PROVIDERS } from '@/utils'
6+
import { useCommand } from '@onelauncher/common'
7+
import { Show, Tooltip, Button, Dropdown } from '@onelauncher/common/components'
8+
import { createFileRoute, Link } from '@tanstack/react-router'
9+
import { openUrl } from '@tauri-apps/plugin-opener'
10+
import { Download01Icon, LinkExternal01Icon, File02Icon, CalendarIcon, ClockRewindIcon, ChevronDownIcon, ChevronUpIcon } from '@untitled-theme/icons-react'
11+
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
12+
import { Collection, ListBox, ListBoxItem, Popover, Select } from 'react-aria-components'
13+
14+
15+
export const Route = createFileRoute('/app/browser/package/$provider/$slug')({
16+
component: RouteComponent,
17+
})
18+
19+
function includes<T, C extends T>(list:{includes: (arg0:C)=>boolean}, element:T): element is C{
20+
return list.includes(element as unknown as C)
21+
}
22+
23+
type PackageContextType = {
24+
pkg: ManagedPackage|undefined
25+
versions: Paginated<ManagedVersion>|undefined
26+
}
27+
28+
const PackageContext = createContext<PackageContextType>({
29+
pkg: undefined,
30+
versions: undefined
31+
})
32+
33+
function RouteComponent() {
34+
const {provider, slug} = Route.useParams()
35+
if(!includes(PROVIDERS, provider)) throw new Error("Invalid provider");
36+
const packageData = usePackageData(provider, slug)
37+
const browserContext = useBrowserContext()
38+
const {data: versions} = usePackageVersions(provider, slug, {
39+
mc_versions: browserContext.cluster ? [browserContext.cluster.mc_version] : null,
40+
loaders: browserContext.cluster ? [browserContext.cluster.mc_loader] : null,
41+
limit: 50})
42+
43+
return (
44+
<PackageContext value={{pkg: packageData.data, versions}}>
45+
<Show when={packageData.isSuccess && !packageData.isFetching}>
46+
<div className="h-full flex flex-1 flex-row items-start gap-x-4">
47+
<BrowserSidebar package={packageData.data!} />
48+
49+
<div className="min-h-full flex flex-1 flex-col items-start gap-y-4 pb-8">
50+
<div className="flex flex-none flex-row gap-x-1 rounded-lg bg-component-bg p-1">
51+
<Link to='/app/browser/package/$provider/$slug' params={{provider, slug}}>About</Link>
52+
<Link to='/app/browser/package/$provider/$slug' params={{provider, slug}}>Versions</Link>
53+
54+
55+
</div>
56+
57+
<div className="h-full min-h-full w-full flex-1">
58+
59+
</div>
60+
</div>
61+
</div>
62+
</Show>
63+
</PackageContext>
64+
)
65+
}
66+
67+
68+
function BrowserSidebar({package: pkg}: { package: ManagedPackage }) {
69+
const {provider} = Route.useParams()
70+
71+
const createdAt = useMemo(() => pkg.created ? new Date(pkg.created) : null, []);
72+
const updatedAt = useMemo(() => pkg.updated ? new Date(pkg.updated) : null, []);
73+
74+
const authors = useCommand("getUsersFromAuthor", ()=>bindings.core.getUsersFromAuthor(provider as Provider, pkg.author))
75+
76+
return (
77+
<div className="sticky top-0 z-1 max-w-60 min-w-54 flex flex-col gap-y-4">
78+
<div className="min-h-72 flex flex-col overflow-hidden rounded-lg bg-component-bg">
79+
<div className="relative h-28 flex items-center justify-center overflow-hidden">
80+
<img alt={`Icon for ${pkg.name}`} className="absolute z-0 max-w-none w-7/6 filter-blur-xl" src={pkg.icon_url || ''} />
81+
<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 || ''} />
82+
</div>
83+
<div className="flex flex-1 flex-col gap-2 p-3">
84+
<div className="flex flex-col gap-2">
85+
<h4 className="text-fg-primary font-medium line-height-snug">{pkg.name}</h4>
86+
<p className="text-xs text-fg-secondary">
87+
<span className="text-fg-primary capitalize">{pkg.package_type}</span>
88+
{' '}
89+
on
90+
{' '}
91+
<span className="text-fg-primary">{pkg.provider}</span>
92+
</p>
93+
</div>
94+
95+
<p className="max-h-22 flex-1 overflow-hidden text-sm text-fg-secondary line-height-snug">{pkg.short_desc}</p>
96+
97+
<div className="flex flex-row gap-4 text-xs">
98+
<Show when={pkg.provider !== 'SkyClient'}>
99+
<div className="flex flex-row items-center gap-2">
100+
<Download01Icon className="h-4 w-4" />
101+
{abbreviateNumber(pkg.downloads)}
102+
</div>
103+
</Show>
104+
</div>
105+
</div>
106+
</div>
107+
108+
<InstallButton {...pkg} />
109+
110+
{/* <div className="flex flex-col gap-2 rounded-lg bg-component-bg p-3">
111+
<h4 className="text-fg-primary font-bold">Links</h4>
112+
<Link href={getPackageUrl(contentPackage)} includeIcon>
113+
{contentPackage.provider}
114+
{' '}
115+
Page
116+
</Link>
117+
</div> */}
118+
119+
<div className="flex flex-col gap-2 rounded-lg bg-component-bg p-3">
120+
<h4 className="text-fg-primary font-bold">Authors</h4>
121+
{
122+
authors.isSuccess
123+
? authors.data.map(author=><Author author={author}/>)
124+
: <h3>Loading...</h3>
125+
}
126+
127+
</div>
128+
129+
<div className="flex flex-col gap-2 rounded-lg bg-component-bg p-3 text-xs!">
130+
<h4 className="text-fg-primary font-bold">Details</h4>
131+
<Show when={pkg.license !== null}>
132+
<div className="flex flex-row items-start gap-x-1">
133+
<File02Icon className="h-3 min-w-3 w-3" />
134+
License
135+
<Link to={pkg.license?.url ?? "#"}>
136+
{pkg.license?.name || pkg.license?.id || 'Unknown'}
137+
</Link>
138+
</div>
139+
</Show>
140+
141+
<Show when={createdAt !== null}>
142+
<Tooltip text={createdAt!.toLocaleString()}>
143+
<div className="flex flex-row items-center gap-x-1">
144+
<CalendarIcon className="h-3 min-w-3 w-3" />
145+
Created
146+
<span className="text-fg-primary font-medium">
147+
{formatAsRelative(createdAt!.getTime(), 'en', 'long')}
148+
</span>
149+
</div>
150+
</Tooltip>
151+
</Show>
152+
153+
<Show when={updatedAt !== null}>
154+
<Tooltip text={updatedAt!.toLocaleString()}>
155+
<div className="flex flex-row items-center gap-x-1">
156+
<ClockRewindIcon className="h-3 min-w-3 w-3" />
157+
Last Updated
158+
<span className="text-fg-primary font-medium">
159+
{formatAsRelative(updatedAt!.getTime(), 'en', 'long')}
160+
</span>
161+
</div>
162+
</Tooltip>
163+
</Show>
164+
</div>
165+
166+
</div>
167+
);
168+
}
169+
170+
171+
function colorForType(type: string) {
172+
switch (type.toLowerCase()) {
173+
case 'release':
174+
return 'bg-code-trace';
175+
case 'snapshot':
176+
return 'bg-code-debug';
177+
case 'beta':
178+
return 'bg-code-warn';
179+
case 'alpha':
180+
return 'bg-code-error';
181+
default:
182+
return 'bg-border/05';
183+
}
184+
}
185+
186+
function InstallButton({...pkg}:ManagedPackage){
187+
const triggerRef = useRef<HTMLButtonElement>(null)
188+
const elementRef = useRef<HTMLDivElement>(null)
189+
const {provider, slug} = Route.useParams()
190+
const [open, setOpen] = useState(false)
191+
const clusters = useClusters()
192+
const browserContext = useBrowserContext()
193+
const {versions} = useContext(PackageContext)
194+
const version = useMemo(()=>{
195+
if(!versions || !browserContext.cluster) return undefined;
196+
return versions.items.findLast(version=>
197+
version.mc_versions.includes(browserContext.cluster!.mc_version)
198+
&& version.loaders.includes(browserContext.cluster!.mc_loader)
199+
)
200+
},[browserContext.cluster, versions])
201+
202+
function download(){
203+
if(!version || !browserContext.cluster || !includes(PROVIDERS, provider)) return false;
204+
downloadPackage(browserContext.cluster, provider, version)
205+
}
206+
207+
208+
return (
209+
<Select onOpenChange={setOpen} isOpen={open}
210+
aria-label='cluster'
211+
onSelectionChange={e=>{
212+
const cluster = clusters?.find(cluster=>cluster.id as unknown as number == e)
213+
if(cluster) browserContext.setCluster(cluster)
214+
}}>
215+
<div className="h-12 flex flex-row w-full" ref={elementRef}>
216+
<Button
217+
color={version ? "primary" : "secondary"}
218+
className="max-w-full flex-1 rounded-r-none!"
219+
onClick={download}
220+
isDisabled={!version}
221+
>
222+
<Download01Icon/>
223+
<div className='w-full text-sm'>
224+
{
225+
browserContext.cluster ?
226+
version
227+
? <span>Download to <br /><span className='text-md font-semibold'>{browserContext.cluster.name}</span></span>
228+
: `No matching version found`
229+
: "Select a Cluster"
230+
}
231+
</div>
232+
</Button>
233+
<Button className="w-8 rounded-l-none border-l border-white/10" onClick={()=>setOpen(!open)} ref={triggerRef}>
234+
{open
235+
? <ChevronUpIcon/>
236+
: <ChevronDownIcon/>
237+
}
238+
</Button>
239+
</div>
240+
<Popover triggerRef={triggerRef} className="mt-1 rounded-lg shadow-md bg-component-bg border border-component-border" style={{width: `${elementRef.current?.clientWidth}px`}}>
241+
<ListBox className="outline-none flex flex-col gap-0.5">
242+
<Collection items={clusters}>
243+
{item=> <ListBoxItem id={item.id as unknown as number}
244+
className="group/item flex flex-row items-center justify-between gap-2 rounded-lg p-2 w-full">{item.name}</ListBoxItem>}
245+
</Collection>
246+
</ListBox>
247+
</Popover>
248+
</Select>
249+
250+
)
251+
}
252+
253+
254+
function Author({author}:{author:ManagedUser}){
255+
256+
//TODO: onclick and fallback avatar url
257+
return (
258+
<a
259+
className="flex flex-row items-center gap-x-1 rounded-md p-1 active:bg-component-bg-pressed hover:bg-component-bg-hover"
260+
onClick={()=>{if(author.url) openUrl(author.url)}}
261+
>
262+
<img alt={`${author.username}'s avatar`} className="h-8 min-h-8 min-w-8 w-8 rounded-[5px]" src={author.avatar_url || ""} />
263+
<div className="flex flex-1 flex-col justify-center gap-y-1">
264+
<span>{author.username}</span>
265+
266+
<Show when={author.is_organization_user}>
267+
<span className="text-xs text-fg-secondary">Organization</span>
268+
</Show>
269+
270+
<Show when={author.role !== null}>
271+
<span className="text-xs text-fg-secondary">{author.role}</span>
272+
</Show>
273+
</div>
274+
<LinkExternal01Icon className="h-4 w-4" />
275+
</a>
276+
277+
)
278+
}
279+
280+
function downloadPackage(cluster: ClusterModel, provider:Provider, version: ManagedVersion, skipCompatibility = false){
281+
return bindings.core.downloadPackage(provider, version.project_id, version.version_id, cluster.id, skipCompatibility)
282+
}

0 commit comments

Comments
 (0)