chore: small fixes and tweaks + external link now has confirmation dialog

This commit is contained in:
LynithDev 2025-08-28 19:11:09 +02:00
parent 37296675e4
commit bd009c1677
10 changed files with 452 additions and 164 deletions

View file

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

View file

@ -232,7 +232,7 @@ export type VersionType =
*/
"old_beta"
const ARGS_MAP = { 'folders':'{"openCluster":["folder_name"],"fromCluster":["folder_name"]}', 'core':'{"getClusterById":["id"],"getPackage":["provider","slug"],"getMultiplePackages":["provider","slugs"],"getLoadersForVersion":["mc_version"],"getClusters":[],"getLogs":["id"],"getProfileOrDefault":["name"],"openMsaLogin":[],"getUsersFromAuthor":["provider","author"],"launchCluster":["id","uuid"],"getLogByName":["id","name"],"fetchMinecraftProfile":["uuid"],"removeUser":["uuid"],"getUser":["uuid"],"killProcess":["pid"],"updateClusterById":["id","request"],"getGameVersions":[],"isClusterRunning":["cluster_id"],"removeCluster":["id"],"createCluster":["options"],"getRunningProcesses":[],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"getPackageBody":["provider","body"],"readSettings":[],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"updateClusterProfile":["name","profile"],"getWorlds":["id"],"getUsers":[],"getRunningProcessesByClusterId":["cluster_id"],"getGlobalProfile":[],"getDefaultUser":["fallback"],"writeSettings":["setting"],"searchPackages":["provider","query"],"setDefaultUser":["uuid"],"getScreenshots":["id"]}', 'events':'{"message":["event"],"process":["event"],"ingress":["event"]}', 'oneclient':'{"getClustersGroupedByMajor":[],"openDevTools":[]}' }
const ARGS_MAP = { 'folders':'{"openCluster":["folder_name"],"fromCluster":["folder_name"]}', 'core':'{"createCluster":["options"],"getGlobalProfile":[],"getLoadersForVersion":["mc_version"],"getScreenshots":["id"],"getUsers":[],"getPackage":["provider","slug"],"getDefaultUser":["fallback"],"getPackageBody":["provider","body"],"writeSettings":["setting"],"open":["input"],"isClusterRunning":["cluster_id"],"getMultiplePackages":["provider","slugs"],"killProcess":["pid"],"updateClusterProfile":["name","profile"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"getUsersFromAuthor":["provider","author"],"getLogByName":["id","name"],"removeUser":["uuid"],"fetchMinecraftProfile":["uuid"],"openMsaLogin":[],"getProfileOrDefault":["name"],"getUser":["uuid"],"getClusters":[],"getWorlds":["id"],"launchCluster":["id","uuid"],"readSettings":[],"removeCluster":["id"],"searchPackages":["provider","query"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"getGameVersions":[],"setDefaultUser":["uuid"],"getClusterById":["id"],"updateClusterById":["id","request"],"getRunningProcessesByClusterId":["cluster_id"],"getLogs":["id"],"getRunningProcesses":[]}', 'oneclient':'{"openDevTools":[],"getClustersGroupedByMajor":[]}', 'events':'{"process":["event"],"ingress":["event"],"message":["event"]}' }
export type Router = { 'folders': { fromCluster: (folderName: string) => Promise<string>,
openCluster: (folderName: string) => Promise<null> },
'core': { getClusters: () => Promise<ClusterModel[]>,
@ -269,7 +269,8 @@ getMultiplePackages: (provider: Provider, slugs: string[]) => Promise<ManagedPac
getPackageVersions: (provider: Provider, slug: string, mcVersion: string | null, loader: GameLoader | null, offset: number, limit: number) => Promise<Paginated<ManagedVersion>>,
downloadPackage: (provider: Provider, packageId: string, versionId: string, clusterId: number, skipCompatibility: boolean | null) => Promise<PackageModel>,
getUsersFromAuthor: (provider: Provider, author: PackageAuthor) => Promise<ManagedUser[]>,
fetchMinecraftProfile: (uuid: string) => Promise<MojangPlayerProfile> },
fetchMinecraftProfile: (uuid: string) => Promise<MojangPlayerProfile>,
open: (input: string) => Promise<null> },
'events': { ingress: (event: IngressPayload) => Promise<void>,
message: (event: MessagePayload) => Promise<void>,
process: (event: ProcessPayload) => Promise<void> },

View file

@ -0,0 +1,81 @@
import type React from 'react';
import type { HTMLProps } from 'react';
import { bindings } from '@/main';
import { LinkExternal01Icon } from '@untitled-theme/icons-react';
import { useState } from 'react';
import { twMerge } from 'tailwind-merge';
import { Overlay } from './overlay';
export interface ExternalLinkProps extends HTMLProps<HTMLAnchorElement> {
href?: string | undefined;
children?: React.ReactNode;
includeIcon?: boolean | undefined;
showModal?: boolean | undefined;
}
export function ExternalLink({
href,
children,
includeIcon,
showModal = true,
className,
...rest
}: ExternalLinkProps) {
const [isOpen, setIsOpen] = useState(false);
// done like this so that "image" links don't have an icon
const withIcon = !!(includeIcon ?? typeof children === 'string');
return (
<Overlay.Trigger isOpen={isOpen} onOpenChange={setIsOpen}>
<a
className={twMerge('text-fg-primary hover:opacity-80 underline', className)}
href={href}
onClick={(e) => {
e.preventDefault();
if (!href)
return;
if (showModal)
setIsOpen(true);
else
bindings.core.open(href);
}}
role="button"
{...rest}
>
{children}
{withIcon && <LinkExternal01Icon className="inline w-4 ml-1" />}
</a>
<Overlay>
<Overlay.Dialog>
<Overlay.Title>External Link</Overlay.Title>
<p>Are you sure you want to open this link in your browser?</p>
<code className="w-full text-center bg-component-bg py-2 rounded-md overflow-auto max-w-100 text-nowrap">{href}</code>
<Overlay.Buttons
buttons={[
{
color: 'secondary',
children: 'Cancel',
slot: 'close',
},
{
color: 'primary',
children: 'Open Link',
onClick: () => {
if (href)
bindings.core.open(href);
setIsOpen(false);
},
},
]}
/>
</Overlay.Dialog>
</Overlay>
</Overlay.Trigger>
);
}

View file

@ -0,0 +1,148 @@
.markdown-body h1:first-child {
margin-block-start: 0;
padding-block-start: 0;
}
.markdown-body blockquote, .markdown-body details, .markdown-body dl, .markdown-body ol, .markdown-body p, .markdown-body code, .markdown-body pre, .markdown-body table, .markdown-body ul {
margin-top: 0;
margin-bottom: 16px;
}
.markdown-body li, .markdown-body p {
padding: 0;
line-height: 1.5;
}
.markdown-body h1 {
display: block;
}
.markdown-body h1, .markdown-body h2, .markdown-body h3 {
color: var(--color-contrast);
}
.markdown-body h1, .markdown-body h2 {
padding: 10px 0 5px;
border-bottom: 1px solid var(--color-divider);
}
.markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4, .markdown-body h5, .markdown-body h6, .markdown-body li, .markdown-body p {
word-wrap: break-word;
overflow-wrap: anywhere;
}
.markdown-body blockquote {
padding: 0 1em;
color: var(--color-base);
border-left: 0.25em solid var(--color-button-bg);
margin-inline: 0;
}
.markdown-body a {
cursor: pointer;
color: var(--color-blue);
}
.markdown-body a:focus-visible, .markdown-body a:hover {
filter: brightness(1.2);
text-decoration: none;
}
.markdown-body a:active {
filter: brightness(1.1);
text-decoration: none;
}
.markdown-body a:active > img {
scale: 0.98;
}
.markdown-body img {
max-width: 100%;
height: auto;
display: inline-block;
}
.markdown-body html:not(.dark-mode, .oled-mode)selfimg[src$='#gh-dark-mode-only'] {
display: none;
}
.markdown-body html:is(.dark-mode, .oled-mode)selfimg[src$='#gh-light-mode-only'] {
display: none;
}
.markdown-body pre {
margin-top: 1rem;
padding: 14px;
border-radius: var(--radius-sm);
background-color: var(--color-button-bg);
overflow-x: auto;
}
.markdown-body pre code {
font-size: 80%;
padding: 0;
border-radius: 0;
}
.markdown-body code {
padding: 0.2em 0.4em;
font-size: 80%;
border-radius: var(--radius-sm);
background-color: var(--color-button-bg);
color: var(--color-contrast);
}
.markdown-body hr {
margin: 20px 0;
color: var(--color-button-bg);
}
.markdown-body table {
display: block;
width: max-content;
max-width: 100%;
overflow: auto;
border-collapse: separate;
border-spacing: 0;
line-height: 1.5;
border: 0.1rem solid var(--color-button-bg);
border-radius: var(--radius-sm);
}
.markdown-body table th {
font-weight: 600;
}
.markdown-body table td, .markdown-body table th {
padding: 0.4rem 0.85rem;
}
.markdown-body table tr:nth-child(2n) {
background-color: var(--color-accent-contrast);
}
.markdown-body table td:not(:last-of-type), .markdown-body table th:not(:last-of-type) {
border-right: 0.1rem solid var(--color-button-bg);
}
.markdown-body table tr:not(:last-of-type) td, .markdown-body table th {
border-bottom: 0.1rem solid var(--color-button-bg);
}
.markdown-body details {
border: 0.15rem solid var(--color-button-bg);
border-radius: var(--radius-sm);
padding: 0.5rem 0.5rem 0;
}
.markdown-body details summary {
font-weight: bold;
margin: -0.5rem -0.5rem 0;
padding: 0.5rem 0.8rem;
cursor: pointer;
background-color: var(--color-button-bg);
border-radius: var(--radius-xs);
}
.markdown-body details summary:hover {
filter: brightness(0.85);
}
.markdown-body details[open] {
padding: 0.5rem;
}
.markdown-body details[open] summary {
margin-bottom: 0.5rem;
border-radius: var(--radius-xs) var(--radius-xs) 0 0;
}
.markdown-body details[open] > :last-child:not(summary) {
margin-bottom: 0 !important;
}
.markdown-body > :last-child {
margin-bottom: 0 !important;
}
.markdown-body iframe, .markdown-body video {
aspect-ratio: 1.7777777778;
width: 850px;
max-width: 100%;
height: auto;
}
@media screen and (max-width: 1280px) {
.markdown-body iframe, .markdown-body video {
width: 100%;
}
}

View file

@ -0,0 +1,28 @@
import ReactMarkdown from 'react-markdown';
import rehypeRaw from 'rehype-raw';
import remarkGfm from 'remark-gfm';
import { twMerge } from 'tailwind-merge';
import { ExternalLink } from './ExternalLink';
import styles from './Markdown.module.css';
export function Markdown({
body,
className,
}: {
body: string;
className?: string | undefined;
}) {
return (
<div className={twMerge(styles.markdown, className)}>
<ReactMarkdown
components={{
a: ({ node, children, ...props }) => <ExternalLink children={children} href={props.href as string} />,
}}
rehypePlugins={[rehypeRaw]}
remarkPlugins={[remarkGfm]}
>
{body}
</ReactMarkdown>
</div>
);
}

View file

@ -1,5 +1,4 @@
import type { Provider, SearchResult } from '@/bindings.gen';
import { Show, Tooltip } from '@onelauncher/common/components';
import { Link } from '@tanstack/react-router';
export function PackageGrid({ items, provider, clusterId }: { items: Array<SearchResult>; provider: Provider; clusterId: number }) {
@ -21,6 +20,7 @@ export function PackageItem({ provider, clusterId, ...item }: SearchResult & { p
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"
preloadDelay={750}
search={{ provider, packageId: item.project_id, clusterId }}
tabIndex={0}
to="/app/cluster/browser/package"
@ -28,36 +28,22 @@ export function PackageItem({ provider, clusterId, ...item }: SearchResult & { p
<div
className="relative flex items-center justify-center overflow-hidden w-full h-28"
>
<Show
fallback={(
<div
className="aspect-ratio-square rounded-md bg-border/05 w-2/5"
/>
)}
when={item.icon_url}
>
<img alt={`Icon for ${item.title}`} className="absolute z-0 max-w-none w-7/6 opacity-50 blur-xl" src={item.icon_url} />
<img
alt={`Icon for ${item.title}`}
className="relative z-1 aspect-ratio-square rounded-lg image-render-auto h-5/6"
src={item.icon_url}
/>
</Show>
{/* <Tooltip className="bg-component-bg-disabled" text={loaders.map(upperFirst).join(', ')}>
<Focusable>
<div className="flex flex-col rounded-full bg-[#11171C]/75 border-component-border/70 border p-1 absolute top-0 right-0 m-2">
{loaders.toSpliced(loaders.length > 3 ? 2 : 3).map(loader => <FlatLoaderIcon className="w-4 m-1" key={loader} loader={loader} />)}
{loaders.length > 3 && (
<div className="bg-component-bg/50 rounded-full w-6 h-6 flex items-center justify-center">
<span className="tracking-tight -ml-0.5 mt-0.5">
+
{loaders.length - 2}
</span>
</div>
)}
</div>
</Focusable>
</Tooltip> */}
{item.icon_url
? (
<>
<img alt={`Icon for ${item.title}`} className="absolute z-0 max-w-none w-7/6 opacity-50 blur-xl" src={item.icon_url} />
<img
alt={`Icon for ${item.title}`}
className="relative z-1 aspect-ratio-square rounded-lg image-render-auto h-5/6"
src={item.icon_url}
/>
</>
)
: (
<div
className="aspect-ratio-square rounded-md bg-border/05 w-2/5"
/>
)}
</div>
<div className="flex flex-1 flex-col gap-2 p-3">
<div className="flex flex-col gap-2">
@ -74,15 +60,6 @@ export function PackageItem({ provider, clusterId, ...item }: SearchResult & { p
</div>
<p className="max-h-22 flex-1 overflow-hidden text-sm text-fg-secondary line-height-snug">{item.description}</p>
{/* <div className="flex flex-row gap-4 text-xs">
<Show when={provider !== 'SkyClient'}>
<div className="flex flex-row items-center gap-2">
<Download01Icon className="h-4 w-4" />
{abbreviateNumber(item.downloads)}
</div>
</Show>
</div> */}
</div>
</Link>
);

View file

@ -1,6 +1,8 @@
import type { ButtonProps } from '@onelauncher/common/components';
import type { HTMLAttributes } from 'react';
import { Button } from '@onelauncher/common/components';
import { XCloseIcon } from '@untitled-theme/icons-react';
import { Dialog, Modal, ModalOverlay } from 'react-aria-components';
import { Dialog, DialogTrigger, Modal, ModalOverlay } from 'react-aria-components';
import { twMerge } from 'tailwind-merge';
import { tv } from 'tailwind-variants';
@ -51,6 +53,34 @@ export function Overlay({
);
}
Overlay.Trigger = DialogTrigger;
Overlay.Buttons = function OverlayButtons({
className,
buttons,
}: Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
buttons?: Array<ButtonProps>;
}) {
return (
<div
className={twMerge(
'flex w-full gap-2 *:flex-1',
(buttons?.length ?? 0) >= 3 ? 'flex-col' : 'flex-row',
className,
)}
>
{buttons?.map(({ key, size, color, ...rest }) => (
<Button
color={color || 'primary'}
key={key}
size={size || 'large'}
{...rest}
/>
))}
</div>
);
};
Overlay.Dialog = function OverlayDialog({
className,
children,

View file

@ -1,13 +1,14 @@
import type { Filters, PackageCategories, PackageType, Provider } from '@/bindings.gen';
import type { Filters, PackageCategories, PackageType, Provider, Sort } from '@/bindings.gen';
import { LoaderSuspense } from '@/components';
import { PackageGrid } from '@/components/PackageItem';
import { bindings } from '@/main';
import { browserCategories, categoryNameFromId } from '@/utils/browser';
import { useCommandSuspense, usePagination } from '@onelauncher/common';
import { Dropdown, Show, TextField } from '@onelauncher/common/components';
import { Dropdown, TextField } from '@onelauncher/common/components';
import { createFileRoute } from '@tanstack/react-router';
import { SearchMdIcon } from '@untitled-theme/icons-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { twMerge } from 'tailwind-merge';
interface BrowserSearchRouteSearchParams {
query?: string;
@ -46,13 +47,10 @@ function RouteComponent() {
<div className="h-full flex flex-col gap-y-4">
<div className="h-full flex-1">
<LoaderSuspense spinner={{ size: 'large' }}>
<SearchResult />
<SearchResults />
</LoaderSuspense>
</div>
</div>
{/* <BrowserSidebar /> */}
</div>
</div>
</div>
@ -60,14 +58,16 @@ function RouteComponent() {
}
function BrowserCategories() {
const search = Route.useSearch();
const { categories, packageType } = search;
const { categories, packageType } = Route.useSearch();
const navigate = Route.useNavigate();
const navRef = useRef(navigate);
const switchCategory = useMemo(() => (category: string) => {
const newCategories = categories.includes(category)
? categories.filter(element => element !== category)
: [...categories, category];
navRef.current({
search: prev => ({
...prev,
@ -78,23 +78,22 @@ function BrowserCategories() {
return (
<div className="top-0 h-fit min-w-50">
{/* <div /> */}
<div className="flex flex-col gap-y-6 ml-16">
<Show when>
<div className="flex flex-col gap-y-1.5">
<h5 className="my-1 text-sm uppercase opacity-60 font-light">Categories</h5>
{browserCategories[categoryNameFromId[packageType]].map(category => (
<p
aria-selected={categories.includes(category)}
className="text-sm capitalize opacity-60 hover:opacity-90 text-fg-primary hover:text-fg-primary-hover aria-selected:opacity-100"
key={category}
onClick={() => switchCategory(category)}
>
{category}
</p>
))}
</div>
</Show>
<div className="flex flex-col gap-y-1.5">
<h5 className="my-1 text-sm uppercase opacity-60 font-light">Categories</h5>
{browserCategories[categoryNameFromId[packageType]].map(category => (
<p
className={twMerge(
'text-sm capitalize opacity-60 hover:opacity-90 text-fg-primary hover:text-fg-primary-hover',
categories.includes(category) && 'opacity-100',
)}
key={category}
onClick={() => switchCategory(category)}
>
{category}
</p>
))}
</div>
</div>
</div>
);
@ -104,10 +103,13 @@ function BrowserToolbar() {
const search = Route.useSearch();
const { provider, query } = search;
const navigate = Route.useNavigate();
const setQuery = (query: string) => {
navigate({ search: { ...search, query } });
};
const [searchBar, setSearchBar] = useState(query);
return (
<div className="w-full flex flex-row justify-between bg-page">
<div className="flex flex-row gap-2">
@ -150,17 +152,18 @@ interface SearchQuery {
query: string | null;
offset: number | null;
limit: number;
sort: 'Relevance' | 'Downloads' | 'Newest' | 'Updated' | null;
sort: Sort;
filters: Filters | null;
}
const itemsPerPage = 25;
function SearchResult() {
function SearchResults() {
const search = Route.useSearch();
const { cluster } = Route.useRouteContext();
const navigate = Route.useNavigate();
const navRef = useRef(navigate);
const query = useMemo<SearchQuery>(() => ({
query: search.query ?? null,
offset: (search.page - 1) * itemsPerPage,
@ -173,30 +176,38 @@ function SearchResult() {
package_type: search.packageType,
},
}), [cluster, search]);
const results = useCommandSuspense(['searchPackages', search], () => bindings.core.searchPackages(
search.provider,
query,
));
const pagination = usePagination({
itemsPerPage: query.limit,
itemsCount: results.data.total,
});
const [oldTotal, setOldTotal] = useState(pagination.totalPages);
const oldTotal = useRef(pagination.totalPages);
const paginationRef = useRef(pagination);
useEffect(() => {
navRef.current({ search: prev => ({ ...prev, page: pagination.page }) });
}, [pagination.page]);
useEffect(() => {
if (pagination.totalPages === oldTotal)
if (pagination.totalPages === oldTotal.current)
return;
paginationRef.current.reset();
setOldTotal(pagination.totalPages);
oldTotal.current = pagination.totalPages;
}, [pagination.totalPages]);
return (
<div className="flex flex-col gap-2">
<div className="flex justify-end">
<pagination.Navigation />
</div>
<PackageGrid clusterId={search.clusterId} items={results.data.items} provider={search.provider} />
</div>
);

View file

@ -1,17 +1,14 @@
import type { ClusterModel, ManagedPackage, ManagedUser, ManagedVersion, PackageDonationUrl, Provider } from '@/bindings.gen';
import type { HTMLProps } from 'react';
import { LoaderSuspense } from '@/components';
import { ExternalLink } from '@/components/ExternalLink';
import { Markdown } from '@/components/Markdown';
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 { abbreviateNumber, formatAsRelative, upperFirst, useCommand, useCommandMut, useCommandSuspense } from '@onelauncher/common';
import { Button, Tooltip } from '@onelauncher/common/components';
import { createFileRoute, redirect } from '@tanstack/react-router';
import { CalendarIcon, ClockRewindIcon, Download01Icon, File02Icon } 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;
@ -22,22 +19,20 @@ export const Route = createFileRoute('/app/cluster/browser/package')({
validateSearch: (search): PackageRouteSearchParams => ({
packageId: search.packageId as string,
}),
async beforeLoad({ context, search }) {
async beforeLoad({ 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 };
const managedPackage = await bindings.core.getPackage(search.provider, search.packageId);
return { managedPackage };
},
});
function RouteComponent() {
const { managedPackage, packageBody } = Route.useRouteContext();
const { managedPackage } = 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} />
@ -45,24 +40,21 @@ function RouteComponent() {
<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>
{/* <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 className="flex-1 w-full" id="about">
<LoaderSuspense>
<BodyPanel />
</LoaderSuspense>
</TabPanel>
<TabPanel id="versions">
<Versions />
<TabPanel className="flex-1 w-full" id="versions">
<LoaderSuspense>
<Versions />
</LoaderSuspense>
</TabPanel>
<TabPanel className="flex flex-wrap gap-2 justify-center" id="gallery">
{/* {managedPackage.gallery.map(item => (
@ -81,26 +73,22 @@ function RouteComponent() {
</TabPanel>
</Tabs>
</div>
// </Show>
);
}
function CustomA({ href, children, includeIcon, className, ...rest }: { href: string; children: any; includeIcon?: boolean } & HTMLProps<HTMLAnchorElement>) {
function BodyPanel() {
const { provider } = Route.useSearch();
const { managedPackage } = Route.useRouteContext();
const { data: body } = useCommandSuspense(['getPackageBody', provider, managedPackage.body], () => bindings.core.getPackageBody(provider, managedPackage.body));
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>
<div 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">
<div className="h-full min-h-full flex-1 w-full rounded-lg bg-component-bg p-3">
<Markdown body={body} />
</div>
</div>
);
}
@ -118,7 +106,7 @@ function BrowserSidebar({ package: pkg }: { package: ManagedPackage }) {
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));
const authors = useCommand(['getUsersFromAuthor', pkg.author], () => bindings.core.getUsersFromAuthor(provider, pkg.author));
return (
<div className="z-1 max-w-60 min-w-54 flex flex-col gap-y-4 mb-6">
@ -142,12 +130,12 @@ function BrowserSidebar({ package: pkg }: { package: ManagedPackage }) {
<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'}>
{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>
@ -157,15 +145,15 @@ function BrowserSidebar({ package: pkg }: { package: ManagedPackage }) {
<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>
<ExternalLink className="text-link hover:text-link-hover" href={getPackageUrl(pkg)} includeIcon>
{provider}
{' '}
Page
</CustomA>
</ExternalLink>
{(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
<ExternalLink
children={upperFirst(link[0])}
className="text-link hover:text-link-hover"
href={link[1]}
@ -174,7 +162,7 @@ function BrowserSidebar({ package: pkg }: { package: ManagedPackage }) {
/>
)
: (link[1] as Array<PackageDonationUrl>).map(donationLink => (
<CustomA
<ExternalLink
children={upperFirst(donationLink.id)}
className="text-link hover:text-link-hover"
href={donationLink.url}
@ -197,87 +185,103 @@ function BrowserSidebar({ package: pkg }: { package: ManagedPackage }) {
<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}>
{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>
<ExternalLink href={pkg.license.url ?? undefined}>
{pkg.license.name || pkg.license.id || 'Unknown'}
</ExternalLink>
</div>
</Show>
)}
<Show when={createdAt !== null}>
<Tooltip text={createdAt!.toLocaleString()}>
{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')}
{formatAsRelative(createdAt.getTime(), 'en', 'long')}
</span>
</div>
</Tooltip>
</Show>
)}
<Show when={updatedAt !== null}>
<Tooltip text={updatedAt!.toLocaleString()}>
{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')}
{formatAsRelative(updatedAt.getTime(), 'en', 'long')}
</span>
</div>
</Tooltip>
</Show>
)}
</div>
</div>
);
}
function usePackageVersions() {
const { provider, packageId } = Route.useSearch();
const { cluster } = Route.useRouteContext();
const { data: versions } = useCommandSuspense(
['getPackageVersions', provider, packageId, cluster.mc_version, cluster.mc_loader],
() => bindings.core.getPackageVersions(provider, packageId, cluster.mc_version, cluster.mc_loader, 0, 64),
);
return versions;
}
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));
const { cluster, managedPackage } = Route.useRouteContext();
const packageVersions = usePackageVersions();
const { provider } = Route.useSearch();
const download = useCommandMut(() => bindings.core.downloadPackage(provider, managedPackage.id, packageVersions.items[0].version_id, cluster.id, true));
return (
<Button
className="flex-col py-4"
isDisabled={packageVersions.total === 0 || !download.isIdle}
onClick={() => download.mutate()}
>
{download.isIdle
<p className="text-lg">{download.isIdle
? 'Download'
: download.isPending
? 'Installing'
: 'Installed'}
</p>
<p className="text-xs">
to
{' '}
<strong>{cluster.name}</strong>
</p>
</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);
}}
<ExternalLink
className="no-underline flex flex-row items-center gap-x-1 rounded-md p-1 active:bg-component-bg-pressed hover:bg-component-bg-hover"
href={author.url ?? undefined}
>
<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">
<div className="flex flex-1 flex-col justify-center gap-y-px">
<span>{author.username}</span>
<Show when={author.is_organization_user}>
{author.is_organization_user && (
<span className="text-xs text-fg-secondary">Organization</span>
</Show>
)}
<Show when={author.role !== null}>
{author.role !== null && (
<span className="text-xs text-fg-secondary">{author.role}</span>
</Show>
)}
</div>
<LinkExternal01Icon className="h-4 w-4" />
</a>
</ExternalLink>
);
}
@ -306,7 +310,7 @@ function Versions() {
// offset,
// });
const { packageVersions: versions } = Route.useRouteContext();
const versions = usePackageVersions();
// const pagination = usePagination({
// itemsCount: versions?.total as unknown as number,
@ -324,7 +328,7 @@ function Versions() {
<div className="flex justify-between">
{/* <pagination.Navigation /> */}
</div>
<Table className="border-separate border-spacing-x-none border-spacing-y-1 w-full">
<Table className="border-separate border-spacing-x-none border-spacing-y-1 flex-1 w-full">
<TableHeader className="text-left">
<Column className="pr-2" />
<Column className="pr-4" isRowHeader>Name</Column>
@ -334,8 +338,8 @@ function Versions() {
<Column className="pr-4">Downloads</Column>
<Column className="pr-4" />
</TableHeader>
<TableBody className="">
{versions?.items.map(item =>
<TableBody className="w-full">
{versions.items.map(item =>
<VersionRow key={item.version_id} version={item} />)}
</TableBody>
</Table>

View file

@ -176,6 +176,9 @@ pub trait TauriLauncherApi {
async fn fetch_player_profile(
uuid: String,
) -> LauncherResult<crate::utils::minecraft::MojangPlayerProfile>;
// MARK: API: Other
async fn open(input: String) -> LauncherResult<()>;
}
#[derive(serde::Serialize, serde::Deserialize, specta::Type, Clone)]
@ -617,4 +620,10 @@ impl TauriLauncherApi for TauriLauncherApiImpl {
) -> LauncherResult<crate::utils::minecraft::MojangPlayerProfile> {
crate::utils::minecraft::fetch_player_profile(&uuid).await
}
// MARK: Impl: Other
async fn open(self, input: String) -> LauncherResult<()> {
opener::open(input)?;
Ok(())
}
}