feat: package page
This commit is contained in:
parent
57ea231e0c
commit
e2e1cdd616
12 changed files with 507 additions and 59 deletions
|
|
@ -14,6 +14,7 @@
|
|||
"@fontsource/poppins": "catalog:",
|
||||
"@formatjs/intl-durationformat": "catalog:",
|
||||
"@onelauncher/common": "workspace:*",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@tanstack/match-sorter-utils": "catalog:",
|
||||
"@tanstack/react-devtools": "catalog:",
|
||||
|
|
@ -27,6 +28,7 @@
|
|||
"@tauri-apps/api": "catalog:",
|
||||
"@tauri-apps/plugin-clipboard-manager": "catalog:",
|
||||
"@tauri-apps/plugin-dialog": "catalog:",
|
||||
"@tauri-apps/plugin-opener": "catalog:",
|
||||
"@untitled-theme/icons-react": "catalog:",
|
||||
"motion": "catalog:",
|
||||
"overlayscrollbars": "catalog:",
|
||||
|
|
@ -35,6 +37,9 @@
|
|||
"react-aria-components": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-just-parallax": "catalog:",
|
||||
"react-markdown": "^10.1.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"skinview3d": "catalog:",
|
||||
"tailwind-merge": "catalog:",
|
||||
"tailwind-variants": "catalog:",
|
||||
|
|
|
|||
|
|
@ -167,7 +167,7 @@ export type SettingProfileModel = { name: string; java_id: number | null; res: R
|
|||
|
||||
export type Settings = { global_game_settings: SettingProfileModel; allow_parallel_running_clusters: boolean; enable_gamemode: boolean; discord_enabled: boolean; max_concurrent_requests: number; settings_version: number; native_window_frame: boolean }
|
||||
|
||||
export type SettingsOsExtra = { enable_gamemode: boolean | null }
|
||||
export type SettingsOsExtra = Record<string, never>
|
||||
|
||||
export type Sort = "Relevance" | "Downloads" | "Newest" | "Updated"
|
||||
|
||||
|
|
@ -230,11 +230,9 @@ export type VersionType =
|
|||
*/
|
||||
"old_beta"
|
||||
|
||||
const ARGS_MAP = { 'folders':'{"fromCluster":["folder_name"],"openCluster":["folder_name"]}', 'events':'{"message":["event"],"ingress":["event"],"process":["event"]}', 'oneclient':'{"openDevTools":[],"getClustersGroupedByMajor":[]}', 'core':'{"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"getProfileOrDefault":["name"],"updateClusterById":["id","request"],"createCluster":["options"],"getLogByName":["id","name"],"getGameVersions":[],"getScreenshots":["id"],"writeSettings":["setting"],"getMultiplePackages":["provider","slugs"],"getLogs":["id"],"removeUser":["uuid"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"getDefaultUser":["fallback"],"getLoadersForVersion":["mc_version"],"getClusters":[],"setDefaultUser":["uuid"],"getGlobalProfile":[],"getClusterById":["id"],"updateClusterProfile":["name","profile"],"getUsers":[],"getUsersFromAuthor":["provider","author"],"openMsaLogin":[],"launchCluster":["id","uuid"],"searchPackages":["provider","query"],"getWorlds":["id"],"getUser":["uuid"],"getPackageBody":["provider","body"],"readSettings":[],"removeCluster":["id"],"getPackage":["provider","slug"],"fetchMinecraftProfile":["uuid"]}' }
|
||||
const ARGS_MAP = { 'core':'{"getClusterById":["id"],"getLogByName":["id","name"],"getLogs":["id"],"openMsaLogin":[],"writeSettings":["setting"],"removeUser":["uuid"],"searchPackages":["provider","query"],"getClusters":[],"updateClusterById":["id","request"],"getUser":["uuid"],"getPackage":["provider","slug"],"getLoadersForVersion":["mc_version"],"getUsers":[],"getScreenshots":["id"],"getPackageBody":["provider","body"],"launchCluster":["id","uuid"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"updateClusterProfile":["name","profile"],"getMultiplePackages":["provider","slugs"],"setDefaultUser":["uuid"],"getWorlds":["id"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"getUsersFromAuthor":["provider","author"],"fetchMinecraftProfile":["uuid"],"removeCluster":["id"],"readSettings":[],"getGlobalProfile":[],"getDefaultUser":["fallback"],"getGameVersions":[],"createCluster":["options"],"getProfileOrDefault":["name"]}', 'events':'{"process":["event"],"ingress":["event"],"message":["event"]}', 'folders':'{"openCluster":["folder_name"],"fromCluster":["folder_name"]}', 'oneclient':'{"getClustersGroupedByMajor":[],"openDevTools":[]}' }
|
||||
export type Router = { 'oneclient': { openDevTools: () => Promise<void>,
|
||||
getClustersGroupedByMajor: () => Promise<Partial<{ [key in number]: ClusterModel[] }>> },
|
||||
'folders': { fromCluster: (folderName: string) => Promise<string>,
|
||||
openCluster: (folderName: string) => Promise<null> },
|
||||
'core': { getClusters: () => Promise<ClusterModel[]>,
|
||||
getClusterById: (id: number) => Promise<ClusterModel | null>,
|
||||
removeCluster: (id: number) => Promise<null>,
|
||||
|
|
@ -268,7 +266,9 @@ getUsersFromAuthor: (provider: Provider, author: PackageAuthor) => Promise<Manag
|
|||
fetchMinecraftProfile: (uuid: string) => Promise<MojangPlayerProfile> },
|
||||
'events': { ingress: (event: IngressPayload) => Promise<void>,
|
||||
message: (event: MessagePayload) => Promise<void>,
|
||||
process: (event: ProcessPayload) => Promise<void> } };
|
||||
process: (event: ProcessPayload) => Promise<void> },
|
||||
'folders': { fromCluster: (folderName: string) => Promise<string>,
|
||||
openCluster: (folderName: string) => Promise<null> } };
|
||||
|
||||
|
||||
export type { InferCommandOutput }
|
||||
|
|
|
|||
|
|
@ -1,38 +1,29 @@
|
|||
import type { Provider, SearchResult } from '@/bindings.gen';
|
||||
// import { abbreviateNumber, LOADERS, upperFirst } from '@/utils';
|
||||
// import { categoryNameFromId } from '@/utils/browser';
|
||||
import { Show, Tooltip } from '@onelauncher/common/components';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { Download01Icon } from '@untitled-theme/icons-react';
|
||||
import { useMemo } from 'react';
|
||||
import { Focusable } from 'react-aria-components';
|
||||
|
||||
function includes<T, TArray extends T>(list: { includes: (arg0: TArray) => boolean }, element: T): element is TArray {
|
||||
return list.includes(element as unknown as TArray);
|
||||
}
|
||||
|
||||
export function PackageGrid({ items, provider }: { items: Array<SearchResult>; provider: Provider }) {
|
||||
export function PackageGrid({ items, provider, clusterId }: { items: Array<SearchResult>; provider: Provider; clusterId: number }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5! gap-4">
|
||||
{items.map(item => (
|
||||
<PackageItem key={item.project_id} {...item} provider={provider} />
|
||||
<PackageItem
|
||||
key={item.project_id}
|
||||
{...item}
|
||||
clusterId={clusterId}
|
||||
provider={provider}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PackageItem({ provider, ...item }: SearchResult & { provider: Provider }) {
|
||||
// const categoryName = categoryNameFromId[item.package_type];
|
||||
// if (!(categoryName in item.categories))
|
||||
// throw new Error('invalid categories');
|
||||
// const loaders = useMemo(() => getCategories(item.categories).filter(cat => includes(LOADERS, cat)), [item.categories]);
|
||||
|
||||
export function PackageItem({ provider, clusterId, ...item }: SearchResult & { provider: Provider; clusterId: number }) {
|
||||
return (
|
||||
<Link
|
||||
className="h-full min-w-50 overflow-hidden rounded-lg bg-component-bg hover:bg-component-bg-hover grid grid-rows-[7rem_auto] max-h-74 min-h-74"
|
||||
params={{ provider, id: item.project_id }}
|
||||
search={{ provider, packageId: item.project_id, clusterId }}
|
||||
tabIndex={0}
|
||||
to="/app/browser/package/$provider/$id"
|
||||
to="/app/cluster/browser/package"
|
||||
>
|
||||
<div
|
||||
className="relative flex items-center justify-center overflow-hidden w-full h-28"
|
||||
|
|
|
|||
|
|
@ -197,7 +197,7 @@ function SearchResult() {
|
|||
<div className="flex justify-end">
|
||||
<pagination.Navigation />
|
||||
</div>
|
||||
<PackageGrid items={results.data.items} provider={search.provider} />
|
||||
<PackageGrid clusterId={search.clusterId} items={results.data.items} provider={search.provider} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,377 @@
|
|||
import type { ClusterModel, ManagedPackage, ManagedUser, ManagedVersion, PackageDonationUrl, Provider } from '@/bindings.gen';
|
||||
import type { HTMLProps } from 'react';
|
||||
import { bindings } from '@/main';
|
||||
import { abbreviateNumber, formatAsRelative, upperFirst, useCommand, useCommandMut } from '@onelauncher/common';
|
||||
import { Button, Show, Tooltip } from '@onelauncher/common/components';
|
||||
import { createFileRoute, Link, redirect } from '@tanstack/react-router';
|
||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
import { CalendarIcon, ClockRewindIcon, Download01Icon, File02Icon, LinkExternal01Icon } from '@untitled-theme/icons-react';
|
||||
import { useMemo } from 'react';
|
||||
import { Cell, Column, Row, Tab, Table, TableBody, TableHeader, TabList, TabPanel, Tabs } from 'react-aria-components';
|
||||
import Markdown from 'react-markdown';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface PackageRouteSearchParams {
|
||||
packageId: string;
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/app/cluster/browser/package')({
|
||||
component: RouteComponent,
|
||||
validateSearch: (search): PackageRouteSearchParams => ({
|
||||
packageId: search.packageId as string,
|
||||
}),
|
||||
async beforeLoad({ context, search }) {
|
||||
if (!search.packageId)
|
||||
throw redirect({ to: '/app/cluster', from: '/app/cluster/browser/package', search });
|
||||
const [managedPackage, packageVersions] = await Promise.all([
|
||||
bindings.core.getPackage(search.provider, search.packageId),
|
||||
bindings.core.getPackageVersions(search.provider, search.packageId, context.cluster.mc_version, context.cluster.mc_loader, 0, 64),
|
||||
]);
|
||||
const packageBody = await bindings.core.getPackageBody(search.provider, managedPackage.body);
|
||||
return { managedPackage, packageBody, packageVersions };
|
||||
},
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { managedPackage, packageBody } = Route.useRouteContext();
|
||||
return (
|
||||
// <Show when={.isSuccess && !packageData.isFetching}>
|
||||
<div className="h-full flex flex-1 flex-row items-start gap-x-4">
|
||||
<BrowserSidebar package={managedPackage} />
|
||||
|
||||
<Tabs className="min-h-full flex flex-1 flex-col items-start gap-y-4 pb-8">
|
||||
<TabList className="flex flex-none flex-row gap-x-1 rounded-lg bg-component-bg p-1 w-min">
|
||||
<Tab className="uppercase p-2.5 text-trim rounded-md selected:bg-component-bg-pressed disabled:hidden" id="about">About</Tab>
|
||||
<Tab className="uppercase p-2.5 text-trim rounded-md selected:bg-component-bg-pressed disabled:hidden" id="versions">Versions</Tab>
|
||||
<Tab className="uppercase p-2.5 text-trim rounded-md selected:bg-component-bg-pressed disabled:hidden" id="gallery" isDisabled={managedPackage.gallery.length === 0}>Gallery</Tab>
|
||||
</TabList>
|
||||
<TabPanel className="prose prose-invert prose-sm max-w-none prose-code:before:content-none prose-code:after:content-none prose-code:bg-component-bg-disabled prose-code:rounded-sm prose-code:p-1! prose-code:text-trim prose-img:inline-block prose-a:inline-block" id="about">
|
||||
<div className="h-full min-h-full flex-1 w-full rounded-lg bg-component-bg p-3">
|
||||
<Markdown
|
||||
components={{
|
||||
a: ({ node, children, ...props }) => <CustomA children={children} href={props.href as string} />,
|
||||
}}
|
||||
rehypePlugins={[rehypeRaw]}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
>
|
||||
{packageBody}
|
||||
</Markdown>
|
||||
</div>
|
||||
</TabPanel>
|
||||
<TabPanel id="versions">
|
||||
<Versions />
|
||||
</TabPanel>
|
||||
<TabPanel className="flex flex-wrap gap-2 justify-center" id="gallery">
|
||||
|
||||
{/* {managedPackage.gallery.map(item => (
|
||||
<Modal.Trigger key={item.url}>
|
||||
<Pressable>
|
||||
<div aria-label="Loaders" className="rounded-md overflow-hidden bg-component-bg-pressed outline-0 h-64 flex flex-col relative">
|
||||
<img className="h-full" src={item.thumbnail_url} />
|
||||
{item.title && <div className="absolute w-full bottom-0 bg-component-bg-disabled/80 p-2">{item.title}</div>}
|
||||
</div>
|
||||
</Pressable>
|
||||
<Modal className="w-full">
|
||||
<img className="rounded-xl" src={item.url} />
|
||||
</Modal>
|
||||
</Modal.Trigger>
|
||||
))} */}
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
</div>
|
||||
// </Show>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomA({ href, children, includeIcon, className, ...rest }: { href: string; children: any; includeIcon?: boolean } & HTMLProps<HTMLAnchorElement>) {
|
||||
return (
|
||||
<a
|
||||
className={twMerge('text-fg-primary underline', className)}
|
||||
href={href}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
openUrl(href);
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
{includeIcon && <LinkExternal01Icon className="inline w-4 ml-1" />}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function getPackageUrl(pkg: ManagedPackage): string {
|
||||
switch (pkg.provider) {
|
||||
case 'Modrinth': return `https://modrinth.com/project/${pkg.slug}`;
|
||||
case 'CurseForge': return `https://www.curseforge.com/minecraft/${pkg.package_type}s/${pkg.slug}`;
|
||||
case 'SkyClient': return ``;
|
||||
}
|
||||
}
|
||||
|
||||
function BrowserSidebar({ package: pkg }: { package: ManagedPackage }) {
|
||||
const { provider } = Route.useSearch();
|
||||
|
||||
const createdAt = useMemo(() => pkg.created ? new Date(pkg.created) : null, [pkg.created]);
|
||||
const updatedAt = useMemo(() => pkg.updated ? new Date(pkg.updated) : null, [pkg.updated]);
|
||||
|
||||
const authors = useCommand(['getUsersFromAuthor', pkg.author], () => bindings.core.getUsersFromAuthor(provider as Provider, pkg.author));
|
||||
|
||||
return (
|
||||
<div className="z-1 max-w-60 min-w-54 flex flex-col gap-y-4 mb-6">
|
||||
<div className="flex flex-col overflow-hidden rounded-lg bg-component-bg">
|
||||
<div className="relative h-28 flex items-center justify-center overflow-hidden">
|
||||
<img alt={`Icon for ${pkg.name}`} className="absolute z-0 max-w-none w-7/6 blur-xl" src={pkg.icon_url || ''} />
|
||||
<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 || ''} />
|
||||
</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-snug">{pkg.name}</h4>
|
||||
<p className="text-xs text-fg-secondary">
|
||||
<span className="text-fg-primary capitalize">{pkg.package_type}</span>
|
||||
{' '}
|
||||
on
|
||||
{' '}
|
||||
<span className="text-fg-primary">{pkg.provider}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="max-h-22 flex-1 overflow-hidden text-sm text-fg-secondary line-height-snug">{pkg.short_desc}</p>
|
||||
|
||||
<div className="flex flex-row gap-4 text-xs">
|
||||
<Show when={pkg.provider !== 'SkyClient'}>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Download01Icon className="h-4 w-4" />
|
||||
{abbreviateNumber(pkg.downloads)}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InstallButton />
|
||||
|
||||
<div className="flex flex-col rounded-lg bg-component-bg p-3">
|
||||
<h4 className="text-fg-primary font-bold">Links</h4>
|
||||
<div className="flex flex-col">
|
||||
<CustomA className="text-link hover:text-link-hover" href={getPackageUrl(pkg)} includeIcon>
|
||||
{provider}
|
||||
{' '}
|
||||
Page
|
||||
</CustomA>
|
||||
{(Object.entries(pkg.links) as Array<[keyof typeof pkg.links, typeof pkg.links[keyof typeof pkg.links]]>).filter(a => a[1]).map(link =>
|
||||
typeof link[1] == 'string'
|
||||
? (
|
||||
<CustomA
|
||||
children={upperFirst(link[0])}
|
||||
className="text-link hover:text-link-hover"
|
||||
href={link[1]}
|
||||
includeIcon
|
||||
key={link[0]}
|
||||
/>
|
||||
)
|
||||
: (link[1] as Array<PackageDonationUrl>).map(donationLink => (
|
||||
<CustomA
|
||||
children={upperFirst(donationLink.id)}
|
||||
className="text-link hover:text-link-hover"
|
||||
href={donationLink.url}
|
||||
includeIcon
|
||||
key={donationLink.id}
|
||||
/>
|
||||
)))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 rounded-lg bg-component-bg p-3">
|
||||
<h4 className="text-fg-primary font-bold">Authors</h4>
|
||||
{
|
||||
authors.isSuccess
|
||||
? authors.data.map(author => <Author author={author} key={author.id} />)
|
||||
: <h3>Loading...</h3>
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 rounded-lg bg-component-bg p-3 text-xs!">
|
||||
<h4 className="text-fg-primary font-bold">Details</h4>
|
||||
<Show when={pkg.license !== null}>
|
||||
<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 ?? '#'}>
|
||||
{pkg.license?.name || pkg.license?.id || 'Unknown'}
|
||||
</Link>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={createdAt !== null}>
|
||||
<Tooltip text={createdAt!.toLocaleString()}>
|
||||
<div className="flex flex-row items-center gap-x-1">
|
||||
<CalendarIcon className="h-3 min-w-3 w-3" />
|
||||
Created
|
||||
<span className="text-fg-primary font-medium">
|
||||
{formatAsRelative(createdAt!.getTime(), 'en', 'long')}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
|
||||
<Show when={updatedAt !== null}>
|
||||
<Tooltip text={updatedAt!.toLocaleString()}>
|
||||
<div className="flex flex-row items-center gap-x-1">
|
||||
<ClockRewindIcon className="h-3 min-w-3 w-3" />
|
||||
Last Updated
|
||||
<span className="text-fg-primary font-medium">
|
||||
{formatAsRelative(updatedAt!.getTime(), 'en', 'long')}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InstallButton() {
|
||||
const { packageVersions, managedPackage } = Route.useRouteContext();
|
||||
const { provider, clusterId } = Route.useSearch();
|
||||
const download = useCommandMut(() => bindings.core.downloadPackage(provider, managedPackage.id, packageVersions.items[0].version_id, clusterId, true));
|
||||
return (
|
||||
<Button
|
||||
isDisabled={packageVersions.total === 0 || !download.isIdle}
|
||||
onClick={() => download.mutate()}
|
||||
>
|
||||
{download.isIdle
|
||||
? 'Download'
|
||||
: download.isPending
|
||||
? 'Installing'
|
||||
: 'Installed'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
function colorForType(type: string) {
|
||||
switch (type.toLowerCase()) {
|
||||
case 'release':
|
||||
return 'bg-code-trace/30';
|
||||
case 'snapshot':
|
||||
return 'bg-code-debug/30';
|
||||
case 'beta':
|
||||
return 'bg-code-warn/30';
|
||||
case 'alpha':
|
||||
return 'bg-code-error/30';
|
||||
default:
|
||||
return 'bg-border/30';
|
||||
}
|
||||
}
|
||||
|
||||
function Versions() {
|
||||
// const { provider } = Route.useSearch();
|
||||
// const [offset, setOffset] = useState(0);
|
||||
|
||||
// const { data: versions } = usePackageVersions(provider, id, {
|
||||
// limit: 20,
|
||||
// offset,
|
||||
// });
|
||||
|
||||
const { packageVersions: versions } = Route.useRouteContext();
|
||||
|
||||
// const pagination = usePagination({
|
||||
// itemsCount: versions?.total as unknown as number,
|
||||
// itemsPerPage: versions?.limit as unknown as number,
|
||||
// });
|
||||
// const paginationRef = useRef(pagination);
|
||||
// useEffect(() => {
|
||||
// setOffset(pagination.offset);
|
||||
// }, [pagination.offset]);
|
||||
// useEffect(() => {
|
||||
// paginationRef.current.reset();
|
||||
// }, [versions?.total]);
|
||||
return (
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex justify-between">
|
||||
{/* <pagination.Navigation /> */}
|
||||
</div>
|
||||
<Table className="border-separate border-spacing-x-none border-spacing-y-1 w-full">
|
||||
<TableHeader className="text-left">
|
||||
<Column className="pr-2" />
|
||||
<Column className="pr-4" isRowHeader>Name</Column>
|
||||
<Column className="pr-4">Game Versions</Column>
|
||||
<Column className="pr-4">Loaders</Column>
|
||||
<Column className="pr-4">Created</Column>
|
||||
<Column className="pr-4">Downloads</Column>
|
||||
<Column className="pr-4" />
|
||||
</TableHeader>
|
||||
<TableBody className="">
|
||||
{versions?.items.map(item =>
|
||||
<VersionRow key={item.version_id} version={item} />)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function VersionRow({ version }: { version: ManagedVersion }) {
|
||||
const { cluster } = Route.useRouteContext();
|
||||
const { provider } = Route.useSearch();
|
||||
return (
|
||||
<Row className="my-2 bg-page-elevated px-4 [&>td]:py-4 w-full">
|
||||
<Cell className="p-4 my-2 bg-component-bg rounded-l-xl">
|
||||
<Tooltip text={upperFirst(version.release_type)}>
|
||||
<div className={`${colorForType(version.release_type)} h-8 w-8 flex items-center justify-center rounded-md`}>
|
||||
<span className="font-bold">{version.release_type.charAt(0).toUpperCase()}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Cell>
|
||||
<Cell className="p-4 px-2 bg-component-bg">{version.display_name}</Cell>
|
||||
<Cell className="p-4 pl-0 bg-component-bg">{version.mc_versions.join(', ')}</Cell>
|
||||
<Cell className="p-4 pl-0 bg-component-bg">{version.loaders.map(upperFirst).join(', ')}</Cell>
|
||||
<Cell className="p-4 pl-0 bg-component-bg">{formatAsRelative(Date.parse(version.published))}</Cell>
|
||||
<Cell className="p-4 pl-0 bg-component-bg">{version.downloads}</Cell>
|
||||
<Cell className="p-4 pl-0 bg-component-bg rounded-r-xl">
|
||||
<Button
|
||||
children={<Download01Icon />}
|
||||
color="secondary"
|
||||
onClick={() => downloadPackage(cluster, provider, version)}
|
||||
size="icon"
|
||||
/>
|
||||
|
||||
</Cell>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
function downloadPackage(cluster: ClusterModel, provider: Provider, version: ManagedVersion, skipCompatibility = false) {
|
||||
return bindings.core.downloadPackage(provider, version.project_id, version.version_id, cluster.id, skipCompatibility);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
@import "tailwindcss";
|
||||
@plugin "tailwindcss-react-aria-components";
|
||||
@plugin "tailwindcss-animated";
|
||||
@plugin "@tailwindcss/typography";
|
||||
@import "../../node_modules/@onelauncher/common/dist/theme.css";
|
||||
@import "./animations.css";
|
||||
|
||||
|
|
|
|||
|
|
@ -3,10 +3,9 @@ import type { HTMLProps } from 'react';
|
|||
import Modal from '@/components/overlay/Modal';
|
||||
import { useBrowserContext, useDownloadPackage, usePackageData, usePackageVersions } from '@/hooks/useBrowser';
|
||||
import { ChooseClusterModal, useClusters } from '@/hooks/useCluster';
|
||||
import usePagination from '@/hooks/usePagination';
|
||||
import { bindings } from '@/main';
|
||||
import { abbreviateNumber, formatAsRelative, includes, PROVIDERS, upperFirst } from '@/utils';
|
||||
import { useCommand } from '@onelauncher/common';
|
||||
import { useCommand, usePagination } 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';
|
||||
|
|
@ -61,7 +60,7 @@ function RouteComponent() {
|
|||
<PackageContext value={packageContextValue}>
|
||||
<Show when={packageData.isSuccess && !packageData.isFetching}>
|
||||
<div className="h-full flex flex-1 flex-row items-start gap-x-4">
|
||||
<BrowserSidebar package={packageData.data!} />
|
||||
<BrowserSidebar package={packageData.data} />
|
||||
|
||||
<Tabs className="min-h-full flex flex-1 flex-col items-start gap-y-4 pb-8">
|
||||
<TabList className="flex flex-none flex-row gap-x-1 rounded-lg bg-component-bg p-1 w-min">
|
||||
|
|
|
|||
|
|
@ -288,8 +288,10 @@ impl ProviderExt for ModrinthProviderImpl {
|
|||
}
|
||||
|
||||
if let Some(loader) = loader {
|
||||
url.query_pairs_mut()
|
||||
.append_pair("loaders", &format!("[\"{}\"]", loader.to_string()));
|
||||
url.query_pairs_mut().append_pair(
|
||||
"loaders",
|
||||
&format!("[\"{}\"]", loader.to_string().to_ascii_lowercase()),
|
||||
);
|
||||
}
|
||||
|
||||
// url.query_pairs_mut()
|
||||
|
|
|
|||
82
packages/web_common/src/utils/formatting.ts
Normal file
82
packages/web_common/src/utils/formatting.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
export function pluralize(n: number, word: string, locale: string = 'en'): string {
|
||||
const pluralRules = new Intl.PluralRules(locale);
|
||||
const pluralForm = pluralRules.select(n);
|
||||
return pluralForm === 'one' ? word : `${word}s`;
|
||||
}
|
||||
|
||||
export function upperFirst<T extends string>(object: T): Capitalize<T> {
|
||||
const string = object.toString();
|
||||
return (string.charAt(0).toUpperCase() + string.slice(1)) as Capitalize<T>;
|
||||
}
|
||||
|
||||
export function abbreviateNumber(n: number | bigint, locale: string = 'en-US'): string {
|
||||
return new Intl.NumberFormat(locale, {
|
||||
notation: 'compact',
|
||||
compactDisplay: 'short',
|
||||
maximumFractionDigits: 1,
|
||||
}).format(n);
|
||||
};
|
||||
|
||||
export function formatAsRelative(
|
||||
seconds: number | bigint | Date,
|
||||
locale: string = 'en',
|
||||
style: 'long' | 'short' | 'narrow' | 'digital' = 'long',
|
||||
): string {
|
||||
let elapsed: number | undefined;
|
||||
|
||||
if (seconds instanceof Date)
|
||||
elapsed = seconds.getTime();
|
||||
else
|
||||
elapsed = Number(seconds);
|
||||
|
||||
elapsed -= Date.now();
|
||||
|
||||
const units = {
|
||||
year: 31536000000,
|
||||
month: 2592000000,
|
||||
week: 604800000,
|
||||
day: 86400000,
|
||||
hour: 3600000,
|
||||
minute: 60000,
|
||||
second: 1000,
|
||||
};
|
||||
|
||||
const formatter = new Intl.RelativeTimeFormat(locale, { style: style as Intl.RelativeTimeFormatStyle });
|
||||
for (const [unit, ms] of Object.entries(units))
|
||||
if (Math.abs(elapsed) > ms || unit === 'second')
|
||||
return formatter.format(Math.round(elapsed / ms), unit as Intl.RelativeTimeFormatUnit);
|
||||
|
||||
return 'now';
|
||||
}
|
||||
|
||||
function convertSeconds(secondsInput: number): string {
|
||||
let remaining = secondsInput;
|
||||
|
||||
const hours = Math.floor(remaining / 3600);
|
||||
remaining %= 3600;
|
||||
|
||||
const minutes = Math.floor(remaining / 60);
|
||||
remaining %= 60;
|
||||
|
||||
const seconds = remaining;
|
||||
|
||||
const parts: Array<string> = [];
|
||||
|
||||
if (hours)
|
||||
parts.push(`${hours} hours`);
|
||||
if (minutes)
|
||||
parts.push(`${minutes} minutes`);
|
||||
if (seconds)
|
||||
parts.push(`${seconds} seconds`);
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
export function formatAsDuration(seconds: number | bigint | Date): string {
|
||||
if (seconds instanceof Date)
|
||||
seconds = seconds.getTime() / 1000;
|
||||
else
|
||||
seconds = Number(seconds);
|
||||
|
||||
return convertSeconds(seconds);
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
export * from './dev';
|
||||
export * from './error';
|
||||
export * from './formatting';
|
||||
export * from './minecraft';
|
||||
export * from './nativeExperience';
|
||||
|
|
|
|||
21
pnpm-lock.yaml
generated
21
pnpm-lock.yaml
generated
|
|
@ -61,7 +61,7 @@ catalogs:
|
|||
specifier: ^2.2.1
|
||||
version: 2.2.1
|
||||
'@tauri-apps/plugin-opener':
|
||||
specifier: ^2.3.0
|
||||
specifier: 2.3.0
|
||||
version: 2.3.0
|
||||
'@tauri-apps/plugin-shell':
|
||||
specifier: ^2.2.1
|
||||
|
|
@ -232,6 +232,9 @@ importers:
|
|||
'@onelauncher/common':
|
||||
specifier: workspace:*
|
||||
version: link:../../../packages/web_common
|
||||
'@tailwindcss/typography':
|
||||
specifier: ^0.5.16
|
||||
version: 0.5.16(tailwindcss@4.1.10)
|
||||
'@tailwindcss/vite':
|
||||
specifier: 'catalog:'
|
||||
version: 4.1.10(vite@6.3.4(@types/node@22.16.5)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.80.3)(tsx@4.19.4)(yaml@2.8.0))
|
||||
|
|
@ -271,6 +274,9 @@ importers:
|
|||
'@tauri-apps/plugin-dialog':
|
||||
specifier: 'catalog:'
|
||||
version: 2.2.1
|
||||
'@tauri-apps/plugin-opener':
|
||||
specifier: 'catalog:'
|
||||
version: 2.3.0
|
||||
'@untitled-theme/icons-react':
|
||||
specifier: 'catalog:'
|
||||
version: 0.14.0(@types/react@19.1.2)(react@19.1.0)
|
||||
|
|
@ -295,6 +301,15 @@ importers:
|
|||
react-just-parallax:
|
||||
specifier: 'catalog:'
|
||||
version: 3.1.16(react@19.1.0)
|
||||
react-markdown:
|
||||
specifier: ^10.1.0
|
||||
version: 10.1.0(@types/react@19.1.2)(react@19.1.0)
|
||||
rehype-raw:
|
||||
specifier: ^7.0.0
|
||||
version: 7.0.0
|
||||
remark-gfm:
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1
|
||||
skinview3d:
|
||||
specifier: 'catalog:'
|
||||
version: 3.4.1
|
||||
|
|
@ -9242,7 +9257,7 @@ snapshots:
|
|||
|
||||
agent-base@7.1.1:
|
||||
dependencies:
|
||||
debug: 4.4.0
|
||||
debug: 4.4.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
|
|
@ -11204,7 +11219,7 @@ snapshots:
|
|||
micromark@4.0.2:
|
||||
dependencies:
|
||||
'@types/debug': 4.1.12
|
||||
debug: 4.4.0
|
||||
debug: 4.4.1
|
||||
decode-named-character-reference: 1.1.0
|
||||
devlop: 1.1.0
|
||||
micromark-core-commonmark: 2.0.3
|
||||
|
|
|
|||
|
|
@ -5,54 +5,33 @@ packages:
|
|||
- .github/actions/*
|
||||
|
||||
catalog:
|
||||
# --
|
||||
# dev dependencies
|
||||
# ---
|
||||
|
||||
# typescript
|
||||
tsx: ^4.19.4
|
||||
typescript: ^5.8.3
|
||||
'@types/node': ^22.13.14
|
||||
'@types/react': ^19.0.8
|
||||
'@types/react-dom': ^19.0.3
|
||||
|
||||
# eslintjs
|
||||
eslint: ^9.20.0
|
||||
'@flowr/eslint': ^6.0.0
|
||||
'@eslint-react/eslint-plugin': ^1.48.5
|
||||
'@tanstack/eslint-config': ^0.1.0
|
||||
|
||||
# vite
|
||||
vite: ^6.3.4
|
||||
vite-plugin-dts: ^4.5.4
|
||||
vite-plugin-static-copy: ^3.0.0
|
||||
vite-plugin-svgr: ^4.3.0
|
||||
'@vitejs/plugin-react': ^4.3.4
|
||||
'@tailwindcss/vite': ^4.0.6
|
||||
|
||||
# other
|
||||
rimraf: ^6.0.1
|
||||
|
||||
# ---
|
||||
# dependencies
|
||||
# ---
|
||||
|
||||
# react + libraries
|
||||
react: ^19.0.0
|
||||
react-aria-components: ^1.8.0
|
||||
'@untitled-theme/icons-react': ^0.14.0
|
||||
motion: ^12.10.5
|
||||
react-dom: ^19.0.0
|
||||
react-just-parallax: ^3.1.16
|
||||
|
||||
# tailwindcss
|
||||
tailwind-merge: ^3.2.0
|
||||
tailwind-variants: ^1.0.0
|
||||
tailwindcss: ^4.0.6
|
||||
tailwindcss-animated: ^2.0.0
|
||||
tailwindcss-react-aria-components: ^2.0.0
|
||||
|
||||
# tanstack
|
||||
'@tanstack/match-sorter-utils': ^8.19.4
|
||||
'@tanstack/react-query-devtools': ^5.66.5
|
||||
'@tanstack/react-router-devtools': ^1.114.3
|
||||
|
|
@ -62,16 +41,12 @@ catalog:
|
|||
'@tanstack/router-plugin': ^1.114.3
|
||||
'@tanstack/react-virtual': ^3.13.12
|
||||
'@tanstack/react-devtools': ^0.4.0
|
||||
|
||||
# tauri
|
||||
taurpc: ^1.8.1
|
||||
'@tauri-apps/api': ^2.5.0
|
||||
'@tauri-apps/plugin-clipboard-manager': ^2.2.2
|
||||
'@tauri-apps/plugin-dialog': ^2.2.1
|
||||
'@tauri-apps/plugin-shell': ^2.2.1
|
||||
'@tauri-apps/plugin-opener': ^2.3.0
|
||||
|
||||
# other
|
||||
'@tauri-apps/plugin-opener': 2.3.0
|
||||
overlayscrollbars: ^2.11.2
|
||||
overlayscrollbars-react: ^0.5.6
|
||||
'@fontsource/poppins': ^5.2.6
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue