|
| 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