feat: package page

This commit is contained in:
Szedann 2025-08-22 12:08:12 +02:00
parent 57ea231e0c
commit e2e1cdd616
12 changed files with 507 additions and 59 deletions

View file

@ -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:",

View file

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

View file

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

View file

@ -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>
);
}

View file

@ -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);
}

View file

@ -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";

View file

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

View file

@ -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()

View 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);
}

View file

@ -1,4 +1,5 @@
export * from './dev';
export * from './error';
export * from './formatting';
export * from './minecraft';
export * from './nativeExperience';

21
pnpm-lock.yaml generated
View file

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

View file

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