513 lines
15 KiB
TypeScript
513 lines
15 KiB
TypeScript
import { For, Match, type ParentProps, Show, Switch, createContext, createEffect, createSignal, on, useContext } from 'solid-js';
|
|
import { A, type Params, Route, useSearchParams } from '@solidjs/router';
|
|
import { CalendarIcon, ChevronDownIcon, ClockRewindIcon, Download01Icon, File02Icon, HeartIcon, LinkExternal01Icon } from '@untitled-theme/icons-solid';
|
|
import type { Cluster, ManagedPackage, ManagedUser, ManagedVersion, Providers } from '@onelauncher/client/bindings';
|
|
import { getLicenseUrl, getPackageUrl, upperFirst } from '../../../utils';
|
|
import { bridge } from '~imports';
|
|
import useCommand from '~ui/hooks/useCommand';
|
|
import { abbreviateNumber, formatAsRelative } from '~utils';
|
|
import Tooltip from '~ui/components/base/Tooltip';
|
|
import Markdown from '~ui/components/content/Markdown';
|
|
import Button from '~ui/components/base/Button';
|
|
import Dropdown from '~ui/components/base/Dropdown';
|
|
import useBrowser from '~ui/hooks/useBrowser';
|
|
import SteveHead from '~assets/images/steve.png';
|
|
import usePromptOpener from '~ui/hooks/usePromptOpener';
|
|
import Link from '~ui/components/base/Link';
|
|
import usePagination from '~ui/hooks/usePagination';
|
|
import Spinner from '~ui/components/Spinner';
|
|
import AnimatedRoutes from '~ui/components/AnimatedRoutes';
|
|
|
|
interface BrowserModParams extends Params {
|
|
id: string;
|
|
provider: Providers;
|
|
}
|
|
|
|
const BrowserPackageContext = createContext<ManagedPackage | null>(null);
|
|
|
|
const basePath = '/browser/package';
|
|
|
|
function BrowserPackageProvider(props: ParentProps & { pkg: ManagedPackage }) {
|
|
return (
|
|
// eslint-disable-next-line solid/reactivity -- Should be fine
|
|
<BrowserPackageContext.Provider value={props.pkg}>
|
|
{props.children}
|
|
</BrowserPackageContext.Provider>
|
|
);
|
|
}
|
|
|
|
function BrowserPackageRoutes() {
|
|
return (
|
|
<>
|
|
<Route path="/" component={BrowserPackageBody} />
|
|
{/* <Route path="/gallery" component={BrowserPackageGallery} /> */}
|
|
<Route path="/versions" component={BrowserPackageVersions} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
function NavLink(props: ParentProps & { href: string }) {
|
|
const [params] = useSearchParams<BrowserModParams>();
|
|
|
|
const url = () => {
|
|
const searchParams = new URLSearchParams(params as Record<string, string>);
|
|
return `${basePath}${props.href}?${searchParams.toString()}`;
|
|
};
|
|
|
|
return (
|
|
<A
|
|
class="rounded-md px-4 py-2 text-sm text-fg-primary font-semibold uppercase active:bg-component-bg-pressed hover:bg-component-bg-hover"
|
|
activeClass="bg-component-bg-pressed hover:bg-component-bg-pressed"
|
|
href={url()}
|
|
children={props.children}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function BrowserPackage(props: ParentProps) {
|
|
const [params] = useSearchParams<BrowserModParams>();
|
|
const [pkg] = useCommand(() => bridge.commands.getProviderPackage(params.provider!, params.id!));
|
|
const [authors] = useCommand(pkg, () => bridge.commands.getProviderAuthors(pkg()!.provider, pkg()!.author));
|
|
|
|
const links = [
|
|
['About', '/'],
|
|
// ['Gallery', '/gallery'],
|
|
['Versions', '/versions'],
|
|
];
|
|
|
|
return (
|
|
<Spinner.Suspense>
|
|
<div class="h-full flex flex-1 flex-row items-start gap-x-4 pb-8">
|
|
<Show
|
|
when={pkg() !== undefined && authors() !== undefined}
|
|
children={(
|
|
<>
|
|
<BrowserSidebar package={pkg()!} authors={authors()!} />
|
|
|
|
<div class="min-h-full flex flex-1 flex-col items-start gap-y-4">
|
|
<div class="flex flex-none flex-row gap-x-1 rounded-lg bg-component-bg p-1">
|
|
<For each={links}>
|
|
{link => (
|
|
<NavLink href={link[1] || ''}>
|
|
{link[0]}
|
|
</NavLink>
|
|
)}
|
|
</For>
|
|
</div>
|
|
|
|
<div class="h-full min-h-full w-full flex-1">
|
|
<BrowserPackageProvider pkg={pkg()!}>
|
|
<Spinner.Suspense>
|
|
<div class="h-full max-w-full min-h-full w-full overflow-hidden">
|
|
<AnimatedRoutes>
|
|
{props.children}
|
|
</AnimatedRoutes>
|
|
</div>
|
|
</Spinner.Suspense>
|
|
</BrowserPackageProvider>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
/>
|
|
</div>
|
|
</Spinner.Suspense>
|
|
);
|
|
}
|
|
|
|
BrowserPackage.buildUrl = function (params: BrowserModParams): string {
|
|
return `${basePath}/?id=${params.id}&provider=${params.provider}`;
|
|
};
|
|
|
|
BrowserPackage.Routes = BrowserPackageRoutes;
|
|
|
|
export default BrowserPackage;
|
|
|
|
function BrowserSidebar(props: { package: ManagedPackage; authors: ManagedUser[] }) {
|
|
const createdAt = () => new Date(props.package.created);
|
|
const updatedAt = () => new Date(props.package.updated);
|
|
const promptOpen = usePromptOpener();
|
|
|
|
return (
|
|
<div class="sticky top-0 z-1 max-w-60 min-w-54 flex flex-col gap-y-4">
|
|
<div class="min-h-72 flex flex-col overflow-hidden rounded-lg bg-component-bg">
|
|
<div class="relative h-28 flex items-center justify-center overflow-hidden">
|
|
<img class="absolute z-0 max-w-none w-7/6 filter-blur-xl" src={props.package.icon_url || ''} alt={`Icon for ${props.package.title}`} />
|
|
<img class="relative z-1 aspect-ratio-square w-2/5 rounded-md image-render-auto" src={props.package.icon_url || ''} alt={`Icon for ${props.package.title}`} />
|
|
</div>
|
|
<div class="flex flex-1 flex-col gap-2 p-3">
|
|
<div class="flex flex-col gap-2">
|
|
<h4 class="text-fg-primary font-medium">{props.package.title}</h4>
|
|
<p class="text-xs text-fg-secondary">
|
|
<span class="text-fg-primary capitalize">{props.package.package_type}</span>
|
|
{' '}
|
|
on
|
|
{' '}
|
|
<span class="text-fg-primary">{props.package.provider}</span>
|
|
</p>
|
|
</div>
|
|
|
|
<p class="max-h-22 flex-1 overflow-hidden text-sm text-fg-secondary line-height-snug">{props.package.description}</p>
|
|
|
|
<div class="flex flex-row gap-4 text-xs">
|
|
<div class="flex flex-row items-center gap-2">
|
|
<Download01Icon class="h-4 w-4" />
|
|
{abbreviateNumber(props.package.downloads)}
|
|
</div>
|
|
|
|
<div class="flex flex-row items-center gap-2">
|
|
<HeartIcon class="h-4 w-4" />
|
|
{abbreviateNumber(props.package.followers)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<InstallButton {...props.package} />
|
|
|
|
<div class="flex flex-col gap-2 rounded-lg bg-component-bg p-3">
|
|
<h4 class="text-fg-primary font-bold">Links</h4>
|
|
<Link includeIcon href={getPackageUrl(props.package.provider, props.package.id, props.package.package_type)}>
|
|
{props.package.provider}
|
|
{' '}
|
|
Page
|
|
</Link>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-2 rounded-lg bg-component-bg p-3">
|
|
<h4 class="text-fg-primary font-bold">Authors</h4>
|
|
<For each={props.authors}>
|
|
{author => (
|
|
<>
|
|
<div
|
|
class="flex flex-row items-center gap-x-1 rounded-md p-1 active:bg-component-bg-pressed hover:bg-component-bg-hover"
|
|
onClick={() => promptOpen(author.url)}
|
|
>
|
|
<img class="h-8 min-h-8 min-w-8 w-8 rounded-md" src={author.avatar_url || SteveHead} alt={`${author.username}'s avatar`} />
|
|
<div class="flex flex-1 flex-col justify-center gap-y-1">
|
|
<span>{author.username}</span>
|
|
|
|
<Show when={author.is_organization_user}>
|
|
<span class="text-xs text-fg-secondary">Organization</span>
|
|
</Show>
|
|
|
|
<Show when={author.role !== null}>
|
|
<span class="text-xs text-fg-secondary">{author.role}</span>
|
|
</Show>
|
|
</div>
|
|
<LinkExternal01Icon class="h-4 w-4" />
|
|
</div>
|
|
<Show when={author.is_organization_user === true}>
|
|
<div class="h-px w-full bg-gray-05" />
|
|
</Show>
|
|
</>
|
|
)}
|
|
</For>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-2 rounded-lg bg-component-bg p-3 text-xs!">
|
|
<h4 class="text-fg-primary font-bold">Details</h4>
|
|
<Show when={props.package.license !== null}>
|
|
<div class="flex flex-row items-start gap-x-1">
|
|
<File02Icon class="h-3 min-w-3 w-3" />
|
|
License
|
|
<Link includeIcon href={getLicenseUrl(props.package.license)}>
|
|
{props.package.license?.name || props.package.license?.id || 'Unknown'}
|
|
</Link>
|
|
</div>
|
|
</Show>
|
|
|
|
<Tooltip text={createdAt().toLocaleString()}>
|
|
<div class="flex flex-row items-center gap-x-1">
|
|
<CalendarIcon class="h-3 min-w-3 w-3" />
|
|
Created
|
|
<span class="text-fg-primary font-medium">
|
|
{formatAsRelative(createdAt().getTime(), 'en', 'long')}
|
|
</span>
|
|
</div>
|
|
</Tooltip>
|
|
|
|
<Tooltip text={updatedAt().toLocaleString()}>
|
|
<div class="flex flex-row items-center gap-x-1">
|
|
<ClockRewindIcon class="h-3 min-w-3 w-3" />
|
|
Last Updated
|
|
<span class="text-fg-primary font-medium">
|
|
{formatAsRelative(updatedAt().getTime(), 'en', 'long')}
|
|
</span>
|
|
</div>
|
|
</Tooltip>
|
|
</div>
|
|
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function InstallButton(props: ManagedPackage) {
|
|
const [selected, setSelected] = createSignal(0);
|
|
const [clusters] = useCommand(() => bridge.commands.getClusters());
|
|
const controller = useBrowser();
|
|
|
|
const meetsRequirements = (cluster: Cluster) => {
|
|
const game_version = props.game_versions.includes(cluster.meta.mc_version);
|
|
const loader = props.loaders.includes(cluster.meta.loader || 'vanilla');
|
|
|
|
return game_version && loader;
|
|
};
|
|
|
|
const filtered = () => clusters()?.filter(meetsRequirements);
|
|
|
|
const getSelectedCluster = () => filtered()?.[selected()];
|
|
|
|
async function download() {
|
|
const cluster = getSelectedCluster();
|
|
|
|
if (cluster === undefined)
|
|
return;
|
|
|
|
// TODO: Add a progress bar
|
|
try {
|
|
await bridge.commands.downloadProviderPackage(
|
|
props.provider,
|
|
props.id,
|
|
cluster.uuid,
|
|
cluster.meta.mc_version,
|
|
cluster.meta.loader || null,
|
|
null, // TODO: Specific package version
|
|
);
|
|
|
|
await bridge.commands.syncClusterPackages(cluster.path || '');
|
|
}
|
|
catch (err) {
|
|
console.error(err);
|
|
}
|
|
}
|
|
|
|
createEffect(() => {
|
|
if (filtered() === undefined)
|
|
return;
|
|
|
|
const cluster = controller.cluster();
|
|
|
|
if (cluster !== undefined && meetsRequirements(cluster)) {
|
|
const index = filtered()!.findIndex(c => c.uuid === cluster.uuid);
|
|
if (index !== -1)
|
|
setSelected(index);
|
|
}
|
|
});
|
|
|
|
return (
|
|
<div class="h-12 flex flex-row">
|
|
|
|
<Button
|
|
buttonStyle="primary"
|
|
iconLeft={<Download01Icon />}
|
|
class="max-w-full flex-1 rounded-r-none!"
|
|
disabled={filtered()?.length === 0}
|
|
onClick={download}
|
|
children={(
|
|
<div class="flex flex-1 flex-col items-center justify-center">
|
|
<p class="text-xs">Download latest to</p>
|
|
<span class="mt-0.5 h-3.5 max-w-38 overflow-x-hidden text-sm font-bold">{filtered?.()?.[selected?.()]?.meta.name || 'Unknown'}</span>
|
|
</div>
|
|
)}
|
|
/>
|
|
|
|
<Dropdown
|
|
class="w-8"
|
|
dropdownClass="w-58! right-0"
|
|
disabled={filtered()?.length === 0}
|
|
selected={selected}
|
|
onChange={setSelected}
|
|
component={props => (
|
|
<Button
|
|
buttonStyle="primary"
|
|
iconLeft={<ChevronDownIcon />}
|
|
class="h-full w-full border-l border-white/5 rounded-l-none! px-0!"
|
|
onClick={() => props.setVisible(true)}
|
|
/>
|
|
)}
|
|
>
|
|
<For each={filtered()}>
|
|
{cluster => (
|
|
<Dropdown.Row>{cluster.meta.name}</Dropdown.Row>
|
|
)}
|
|
</For>
|
|
</Dropdown>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Sub pages
|
|
function BrowserPackageBody() {
|
|
const context = useContext(BrowserPackageContext);
|
|
|
|
return (
|
|
<div class="w-full flex-1 rounded-lg bg-component-bg p-4 px-6">
|
|
<Markdown body={context?.body || ''} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// function BrowserPackageGallery() {
|
|
// // const context = useContext(BrowserPackageContext);
|
|
|
|
// return (
|
|
// <div class="w-full flex-1 rounded-lg bg-component-bg p-4 px-6">
|
|
// <div>Gallery</div>
|
|
// {/* <For each={[]}>
|
|
// {image => (
|
|
// <img src={image.url} alt={image.alt} />
|
|
// )}
|
|
// </For> */}
|
|
// </div>
|
|
// );
|
|
// }
|
|
|
|
function BrowserPackageVersions() {
|
|
const context = useContext(BrowserPackageContext);
|
|
const MAX_ITEMS_PER_PAGE = 20;
|
|
|
|
const { page, Navigation } = usePagination({
|
|
itemsCount: () => context?.versions.length || 0,
|
|
itemsPerPage: () => MAX_ITEMS_PER_PAGE,
|
|
});
|
|
|
|
const getVersionsForPage = (page: number) => {
|
|
if (context === null)
|
|
return [];
|
|
|
|
const newestToOldest = context.versions.toReversed();
|
|
const start = (page - 1) * MAX_ITEMS_PER_PAGE;
|
|
const end = start + MAX_ITEMS_PER_PAGE;
|
|
|
|
return newestToOldest.slice(start, end);
|
|
};
|
|
|
|
const [versions, { refetch }] = useCommand(context, async () => {
|
|
if (context === null)
|
|
return { error: 'No package context', status: 'error' };
|
|
|
|
const list = getVersionsForPage(page());
|
|
|
|
return await bridge.commands.getProviderPackageVersions(context.provider, list);
|
|
});
|
|
|
|
let container!: HTMLDivElement;
|
|
|
|
createEffect(on(() => page(), () => {
|
|
refetch();
|
|
container.parentElement?.parentElement?.scrollIntoView({ behavior: 'smooth' });
|
|
}));
|
|
|
|
return (
|
|
<div ref={container} class="h-full w-full flex flex-1 flex-col gap-y-1">
|
|
<div class="flex flex-row justify-between">
|
|
<h1>
|
|
Versions - Page
|
|
{' '}
|
|
{page()}
|
|
</h1>
|
|
|
|
<Navigation />
|
|
</div>
|
|
|
|
<Spinner.Suspense>
|
|
<table class="w-full border-separate border-spacing-x-none border-spacing-y-1">
|
|
<thead>
|
|
<tr class="bg-page-elevated [&>th]:py-2 [&>th]:text-left">
|
|
<th class="w-16 rounded-l-lg" />
|
|
|
|
<th>Name</th>
|
|
<th>Game Version</th>
|
|
<th>Loader</th>
|
|
<th>Created</th>
|
|
<th>Downloads</th>
|
|
<th class="rounded-r-lg" />
|
|
</tr>
|
|
</thead>
|
|
|
|
<tbody>
|
|
<For each={versions()?.reverse()}>
|
|
{version => <VersionRow {...version} />}
|
|
</For>
|
|
</tbody>
|
|
</table>
|
|
</Spinner.Suspense>
|
|
|
|
<Navigation />
|
|
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function colorForType(type: string) {
|
|
switch (type) {
|
|
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-gray-05';
|
|
}
|
|
}
|
|
|
|
function VersionRow(props: ManagedVersion) {
|
|
return (
|
|
<tr class="my-2 bg-page-elevated px-4 [&>td]:py-4">
|
|
<td class="rounded-l-lg px-4">
|
|
<Tooltip text={upperFirst(props.version_type)}>
|
|
<div class={`${colorForType(props.version_type)} h-8 w-8 flex items-center justify-center rounded-md bg-opacity-30`}>
|
|
<span class="font-bold">{props.version_type.charAt(0).toUpperCase()}</span>
|
|
</div>
|
|
</Tooltip>
|
|
</td>
|
|
|
|
<td>
|
|
<div class="flex flex-col gap-2">
|
|
<h3 class="text-lg">{props.version_id}</h3>
|
|
<p class="text-wrap text-sm">{props.name}</p>
|
|
</div>
|
|
</td>
|
|
|
|
<td>
|
|
<Switch>
|
|
<Match when={props.game_versions.length > 3}>
|
|
<Tooltip text={props.game_versions.join(', ')}>
|
|
<span>
|
|
{props.game_versions.slice(-3).toReversed().join(', ')}
|
|
...
|
|
</span>
|
|
</Tooltip>
|
|
</Match>
|
|
<Match when>
|
|
<span>{props.game_versions.join(', ')}</span>
|
|
</Match>
|
|
</Switch>
|
|
</td>
|
|
|
|
<td>
|
|
{props.loaders.map(upperFirst).join(', ')}
|
|
</td>
|
|
|
|
<td>
|
|
{formatAsRelative(new Date(props.published).getTime(), 'en', 'long')}
|
|
</td>
|
|
|
|
<td>
|
|
{props.downloads.toLocaleString()}
|
|
</td>
|
|
|
|
<td class="rounded-r-lg">
|
|
<Button
|
|
buttonStyle="iconSecondary"
|
|
children={<Download01Icon />}
|
|
/>
|
|
</td>
|
|
|
|
</tr>
|
|
);
|
|
}
|