Merge branch 'oneclient/main' of https://github.com/Polyfrost/OneLauncher into oneclient/main
This commit is contained in:
commit
4aaf4aebaa
36 changed files with 1269 additions and 268 deletions
12
.vscode/settings.json
vendored
12
.vscode/settings.json
vendored
|
|
@ -2,6 +2,18 @@
|
|||
"[rust]": {
|
||||
"editor.defaultFormatter": "rust-lang.rust-analyzer"
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
|
||||
},
|
||||
"[javascriptreact]": {
|
||||
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "rvest.vs-code-prettier-eslint"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
|
||||
},
|
||||
"files.readonlyInclude": {
|
||||
"**/node_modules/**": true,
|
||||
"**/*.gen.ts": true
|
||||
|
|
|
|||
|
|
@ -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:",
|
||||
|
|
@ -38,6 +37,7 @@
|
|||
"react-dom": "catalog:",
|
||||
"react-just-parallax": "catalog:",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-toastify": "catalog:",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"skinview3d": "catalog:",
|
||||
|
|
|
|||
|
|
@ -147,7 +147,9 @@ export type Process = { pid: number; started_at: string; cluster_id: number; pos
|
|||
|
||||
export type ProcessError = { type: "HookUnsuccessful"; data: string } | { type: "NoPid"; data: string }
|
||||
|
||||
export type ProcessPayload = { type: "Starting"; command: string } | { type: "Started"; process: Process } | { type: "Stopped"; pid: number; exit_code: number } | { type: "Output"; pid: number; output: string }
|
||||
export type ProcessPayload = { cluster_id: number; kind: ProcessPayloadKind }
|
||||
|
||||
export type ProcessPayloadKind = { type: "Starting"; command: string } | { type: "Started"; pid: number; started_at: string; post_hook: string | null; account_id: string } | { type: "Stopped"; pid: number; exit_code: number } | { type: "Output"; pid: number; output: string }
|
||||
|
||||
export type ProfileUpdate = { res: Resolution | null; force_fullscreen: boolean | null; mem_max: number | null; launch_args: string | null; launch_env: string | null; hook_pre: string | null; hook_wrapper: string | null; hook_post: string | null }
|
||||
|
||||
|
|
@ -230,9 +232,10 @@ export type VersionType =
|
|||
*/
|
||||
"old_beta"
|
||||
|
||||
const ARGS_MAP = { 'folders':'{"fromCluster":["folder_name"],"openCluster":["folder_name"]}', 'events':'{"message":["event"],"process":["event"],"ingress":["event"]}', 'core':'{"readSettings":[],"getLogs":["id"],"getGameVersions":[],"getClusters":[],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"launchCluster":["id","uuid"],"getClusterById":["id"],"getUsersFromAuthor":["provider","author"],"setDefaultUser":["uuid"],"removeUser":["uuid"],"updateClusterProfile":["name","profile"],"openMsaLogin":[],"getDefaultUser":["fallback"],"getScreenshots":["id"],"removeCluster":["id"],"getMultiplePackages":["provider","slugs"],"writeSettings":["setting"],"getUsers":[],"createCluster":["options"],"getWorlds":["id"],"updateClusterById":["id","request"],"getGlobalProfile":[],"getUser":["uuid"],"getLogByName":["id","name"],"searchPackages":["provider","query"],"getProfileOrDefault":["name"],"getLoadersForVersion":["mc_version"],"getPackage":["provider","slug"],"getPackageBody":["provider","body"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"fetchMinecraftProfile":["uuid"]}', 'oneclient':'{"openDevTools":[],"getClustersGroupedByMajor":[]}' }
|
||||
export type Router = { 'oneclient': { openDevTools: () => Promise<void>,
|
||||
getClustersGroupedByMajor: () => Promise<Partial<{ [key in number]: ClusterModel[] }>> },
|
||||
const ARGS_MAP = { 'core':'{"writeSettings":["setting"],"getWorlds":["id"],"getRunningProcessesByClusterId":["cluster_id"],"updateClusterProfile":["name","profile"],"getClusterById":["id"],"openMsaLogin":[],"getScreenshots":["id"],"getUsers":[],"getProfileOrDefault":["name"],"updateClusterById":["id","request"],"getDefaultUser":["fallback"],"removeCluster":["id"],"createCluster":["options"],"getPackage":["provider","slug"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"getUsersFromAuthor":["provider","author"],"open":["input"],"getMultiplePackages":["provider","slugs"],"getLogByName":["id","name"],"getLoadersForVersion":["mc_version"],"isClusterRunning":["cluster_id"],"searchPackages":["provider","query"],"setDefaultUser":["uuid"],"getUser":["uuid"],"killProcess":["pid"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"launchCluster":["id","uuid"],"getClusters":[],"getRunningProcesses":[],"getGameVersions":[],"getGlobalProfile":[],"getPackageBody":["provider","body"],"removeUser":["uuid"],"fetchMinecraftProfile":["uuid"],"readSettings":[],"getLogs":["id"]}', 'oneclient':'{"getClustersGroupedByMajor":[],"openDevTools":[]}', 'events':'{"message":["event"],"ingress":["event"],"process":["event"]}', 'folders':'{"fromCluster":["folder_name"],"openCluster":["folder_name"]}' }
|
||||
export type Router = { 'events': { ingress: (event: IngressPayload) => Promise<void>,
|
||||
message: (event: MessagePayload) => Promise<void>,
|
||||
process: (event: ProcessPayload) => Promise<void> },
|
||||
'core': { getClusters: () => Promise<ClusterModel[]>,
|
||||
getClusterById: (id: number) => Promise<ClusterModel | null>,
|
||||
removeCluster: (id: number) => Promise<null>,
|
||||
|
|
@ -243,6 +246,10 @@ getScreenshots: (id: number) => Promise<string[]>,
|
|||
getWorlds: (id: number) => Promise<string[]>,
|
||||
getLogs: (id: number) => Promise<string[]>,
|
||||
getLogByName: (id: number, name: string) => Promise<string | null>,
|
||||
getRunningProcesses: () => Promise<Process[]>,
|
||||
getRunningProcessesByClusterId: (clusterId: number) => Promise<Process[]>,
|
||||
isClusterRunning: (clusterId: number) => Promise<boolean>,
|
||||
killProcess: (pid: number) => Promise<null>,
|
||||
getProfileOrDefault: (name: string | null) => Promise<SettingProfileModel>,
|
||||
getGlobalProfile: () => Promise<SettingProfileModel>,
|
||||
updateClusterProfile: (name: string, profile: ProfileUpdate) => Promise<SettingProfileModel>,
|
||||
|
|
@ -263,10 +270,10 @@ 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> },
|
||||
'events': { ingress: (event: IngressPayload) => Promise<void>,
|
||||
message: (event: MessagePayload) => Promise<void>,
|
||||
process: (event: ProcessPayload) => Promise<void> },
|
||||
fetchMinecraftProfile: (uuid: string) => Promise<MojangPlayerProfile>,
|
||||
open: (input: string) => Promise<null> },
|
||||
'oneclient': { openDevTools: () => Promise<void>,
|
||||
getClustersGroupedByMajor: () => Promise<Partial<{ [key in number]: ClusterModel[] }>> },
|
||||
'folders': { fromCluster: (folderName: string) => Promise<string>,
|
||||
openCluster: (folderName: string) => Promise<null> } };
|
||||
|
||||
|
|
|
|||
81
apps/oneclient/frontend/src/components/ExternalLink.tsx
Normal file
81
apps/oneclient/frontend/src/components/ExternalLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
apps/oneclient/frontend/src/components/LaunchButton.tsx
Normal file
38
apps/oneclient/frontend/src/components/LaunchButton.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import type { ButtonProps } from '@onelauncher/common/components';
|
||||
import { useIsRunning } from '@/hooks/useClusters';
|
||||
import { useLaunchCluster } from '@/hooks/useLaunchCluster';
|
||||
import { Button } from '@onelauncher/common/components';
|
||||
import { tv } from 'tailwind-variants';
|
||||
|
||||
export type LaunchButtonProps = Omit<ButtonProps, 'children' | 'onClick'> & {
|
||||
clusterId: number | undefined | null;
|
||||
};
|
||||
|
||||
const launchButtonVariants = tv({
|
||||
variants: {
|
||||
isRunning: {
|
||||
true: 'bg-success hover:bg-success-hover pressed:bg-success-pressed disabled:bg-success-disabled pending:bg-success-disabled',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function LaunchButton({
|
||||
clusterId,
|
||||
isDisabled,
|
||||
className,
|
||||
...rest
|
||||
}: LaunchButtonProps) {
|
||||
const launch = useLaunchCluster(clusterId);
|
||||
const isRunning = useIsRunning(clusterId);
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={launchButtonVariants({ isRunning, className })}
|
||||
isDisabled={isDisabled || isRunning}
|
||||
onClick={launch}
|
||||
{...rest}
|
||||
>
|
||||
{isRunning ? 'Running' : 'Launch'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
@reference "../styles/global.css";
|
||||
|
||||
.log_container {
|
||||
@apply w-full whitespace-pre text-base font-mono bg-component-bg rounded-xl;
|
||||
@apply relative w-full whitespace-pre text-base font-mono bg-component-bg rounded-xl;
|
||||
}
|
||||
|
||||
.log {
|
||||
@apply relative w-full whitespace-pre text-base font-mono;
|
||||
@apply absolute top-0 left-0 w-full whitespace-pre text-base font-mono;
|
||||
}
|
||||
|
||||
.log * {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
|
||||
import { useImperativeHandle, useMemo, useState } from 'react';
|
||||
import { useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react';
|
||||
import styles from './LogViewer.module.css';
|
||||
|
||||
export interface LogViewerProps {
|
||||
content: string;
|
||||
scrollRef: React.RefObject<HTMLElement | null>;
|
||||
autoScroll?: boolean;
|
||||
ref?: React.RefObject<LogViewerRef>;
|
||||
ref?: React.Ref<LogViewerRef>;
|
||||
}
|
||||
|
||||
export interface LogViewerRef {
|
||||
|
|
@ -25,6 +25,7 @@ export function LogViewer({
|
|||
ref,
|
||||
}: LogViewerProps) {
|
||||
const lines = useMemo<Array<string>>(() => content.split('\n'), [content]);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// used to force re-render (and re-init of virtualizer hook)
|
||||
const [_lastUpdated, setLastUpdated] = useState(Date.now());
|
||||
|
|
@ -36,21 +37,30 @@ export function LogViewer({
|
|||
paddingStart: 4,
|
||||
paddingEnd: 4,
|
||||
gap: 0,
|
||||
overscan: 20,
|
||||
overscan: 8,
|
||||
scrollMargin: containerRef.current ? containerRef.current.offsetTop + 75 : 0,
|
||||
});
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
push: (line) => {
|
||||
const len = lines.push(line);
|
||||
setLastUpdated(Date.now());
|
||||
const scrollToEnd = useCallback(() => {
|
||||
requestAnimationFrame(() => scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }));
|
||||
}, [scrollRef]);
|
||||
|
||||
if (autoScroll)
|
||||
virtualizer.scrollToIndex(len - 1);
|
||||
},
|
||||
}), [autoScroll, lines, virtualizer]);
|
||||
const appendLine = useCallback((line: string) => {
|
||||
setLastUpdated(Date.now());
|
||||
lines.push(line);
|
||||
|
||||
if (autoScroll)
|
||||
scrollToEnd();
|
||||
}, [autoScroll, lines, scrollToEnd]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
push: appendLine,
|
||||
scrollToEnd,
|
||||
}), [appendLine, scrollToEnd]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
}}
|
||||
|
|
@ -67,7 +77,6 @@ export function LogViewer({
|
|||
height: '100%',
|
||||
}}
|
||||
>
|
||||
|
||||
<code className={styles.log}>
|
||||
{virtualizer.getVirtualItems().map(virtualRow => (
|
||||
<LogLine
|
||||
|
|
@ -75,7 +84,7 @@ export function LogViewer({
|
|||
line={lines[virtualRow.index]}
|
||||
style={{
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
transform: `translateY(${virtualRow.start - virtualizer.options.scrollMargin}px)`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -109,7 +118,7 @@ function LogLine({
|
|||
<span data-level={level} style={style}>
|
||||
<span data-time={time}>[{time}]</span>
|
||||
<span>[{threadName}/{level}]</span>
|
||||
<span data-chat={isChat}>{isChat ? ' [CHAT] ' : ' '}{line.slice(matched.length)}</span>
|
||||
<span data-chat={isChat ? 'true' : undefined}>{isChat ? ' [CHAT]' : ' '}{line.slice(matched.length)}</span>
|
||||
<br />
|
||||
</span>
|
||||
);
|
||||
|
|
|
|||
148
apps/oneclient/frontend/src/components/Markdown.module.css
Normal file
148
apps/oneclient/frontend/src/components/Markdown.module.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
|
||||
28
apps/oneclient/frontend/src/components/Markdown.tsx
Normal file
28
apps/oneclient/frontend/src/components/Markdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,7 +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"
|
||||
preload={false}
|
||||
preloadDelay={750}
|
||||
search={{ provider, packageId: item.project_id, clusterId }}
|
||||
tabIndex={0}
|
||||
to="/app/cluster/browser/package"
|
||||
|
|
@ -29,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">
|
||||
|
|
@ -75,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>
|
||||
);
|
||||
|
|
|
|||
34
apps/oneclient/frontend/src/components/Spinner.module.css
Normal file
34
apps/oneclient/frontend/src/components/Spinner.module.css
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
@keyframes animloader {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.loader {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.loader::after,
|
||||
.loader::before {
|
||||
content: '';
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #FFF;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
opacity: 0;
|
||||
animation: animloader 2s linear infinite;
|
||||
}
|
||||
|
||||
.loader::after {
|
||||
animation-delay: 1s;
|
||||
}
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
import type { HTMLAttributes } from 'react';
|
||||
import type { VariantProps } from 'tailwind-variants';
|
||||
import { tv } from 'tailwind-variants';
|
||||
import styles from './Spinner.module.css';
|
||||
|
||||
const spinnerVariants = tv({
|
||||
base: 'inline-block relative opacity-0 animate-delay-1000 animate-fade animate-fill-forwards',
|
||||
base: styles.loader,
|
||||
variants: {
|
||||
size: {
|
||||
extraSmall: 'w-6 h-6',
|
||||
small: 'w-8 h-8',
|
||||
medium: 'w-12 h-12',
|
||||
large: 'w-16 h-16',
|
||||
|
|
@ -24,9 +26,6 @@ export function Spinner({
|
|||
className,
|
||||
}: SpinnerProps) {
|
||||
return (
|
||||
<div className={spinnerVariants({ size, className })}>
|
||||
<div className="absolute w-full h-full rounded-full border-2 border-fg-primary animate-fade-scale"></div>
|
||||
<div className="absolute w-full h-full rounded-full border-2 border-fg-primary animate-fade-scale animate-delay-500"></div>
|
||||
</div>
|
||||
<div className={spinnerVariants({ size, className })}></div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
:root {
|
||||
--toastify-color-light: transparent;
|
||||
--toastify-color-dark: transparent;
|
||||
--toastify-color-info: var(--toastify-color-dark);
|
||||
--toastify-color-success: var(--color-success);
|
||||
--toastify-color-warning: var(--color-warning);
|
||||
--toastify-color-error: var(--color-error);
|
||||
--toastify-color-transparent: rgba(255, 255, 255, 0.7);
|
||||
|
||||
--toastify-icon-color-info: var(--color-code-info);
|
||||
--toastify-icon-color-success: var(--color-code-success);
|
||||
--toastify-icon-color-warning: var(--color-code-warning);
|
||||
--toastify-icon-color-error: var(--color-code-error);
|
||||
|
||||
--toastify-container-width: fit-content;
|
||||
--toastify-toast-width: 320px;
|
||||
--toastify-toast-offset: 16px;
|
||||
--toastify-toast-top: max(var(--toastify-toast-offset), env(safe-area-inset-top));
|
||||
--toastify-toast-right: max(var(--toastify-toast-offset), env(safe-area-inset-right));
|
||||
--toastify-toast-left: max(var(--toastify-toast-offset), env(safe-area-inset-left));
|
||||
--toastify-toast-bottom: max(var(--toastify-toast-offset), env(safe-area-inset-bottom));
|
||||
--toastify-toast-padding: 0px;
|
||||
--toastify-toast-min-height: 0px;
|
||||
--toastify-toast-max-height: 800px;
|
||||
--toastify-toast-bd-radius: 0px;
|
||||
--toastify-toast-shadow: var(--shadow-lg);
|
||||
--toastify-font-family: var(--font-sans);
|
||||
--toastify-z-index: 9999;
|
||||
--toastify-text-color-light: var(--color-fg-primary);
|
||||
--toastify-text-color-dark: var(--color-fg-primary);
|
||||
|
||||
/* Used only for colored theme */
|
||||
--toastify-text-color-info: #fff;
|
||||
--toastify-text-color-success: #fff;
|
||||
--toastify-text-color-warning: #fff;
|
||||
--toastify-text-color-error: #fff;
|
||||
|
||||
--toastify-spinner-color: #616161;
|
||||
--toastify-spinner-color-empty-area: #e0e0e0;
|
||||
--toastify-color-progress-light: linear-gradient(to right, #4cd964, #5ac8fa, #007aff, #34aadc, #5856d6, #ff2d55);
|
||||
--toastify-color-progress-dark: #bb86fc;
|
||||
--toastify-color-progress-info: var(--toastify-color-info);
|
||||
--toastify-color-progress-success: var(--toastify-color-success);
|
||||
--toastify-color-progress-warning: var(--toastify-color-warning);
|
||||
--toastify-color-progress-error: var(--toastify-color-error);
|
||||
/* used to control the opacity of the progress trail */
|
||||
--toastify-color-progress-bgo: 0.2;
|
||||
}
|
||||
|
||||
@keyframes custom-anim-progress-bar {
|
||||
0% {
|
||||
width: 0%;
|
||||
}
|
||||
100% {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
189
apps/oneclient/frontend/src/components/overlay/Toasts.tsx
Normal file
189
apps/oneclient/frontend/src/components/overlay/Toasts.tsx
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import type { ToastData, ToastId } from '@/utils/toast';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import type { ToastContentProps, ToastOptions } from 'react-toastify';
|
||||
import { bindings } from '@/main';
|
||||
import { toast, toastUpdate } from '@/utils/toast';
|
||||
import { Button } from '@onelauncher/common/components';
|
||||
import { AlertCircleIcon, AlertTriangleIcon, CheckCircleIcon, InfoCircleIcon, XCloseIcon } from '@untitled-theme/icons-react';
|
||||
import { useEffect } from 'react';
|
||||
import { cssTransition, ToastContainer } from 'react-toastify';
|
||||
import { tv } from 'tailwind-variants';
|
||||
import { Spinner } from '../Spinner';
|
||||
import styles from './Toasts.module.css';
|
||||
|
||||
const toastVariants = tv({
|
||||
slots: {
|
||||
container: [
|
||||
'relative h-full flex flex-row w-full justify-start items-center gap-3 py-4 px-4 rounded-lg overflow-hidden',
|
||||
'inset-ring inset-ring-component-border bg-component-bg',
|
||||
],
|
||||
closeBtn: [
|
||||
'absolute top-2 right-2',
|
||||
],
|
||||
},
|
||||
variants: {
|
||||
clickable: {
|
||||
true: {
|
||||
container: [
|
||||
'hover:bg-component-bg-hover active:bg-component-bg-pressed',
|
||||
],
|
||||
},
|
||||
false: {
|
||||
container: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function Toast({
|
||||
closeToast,
|
||||
data,
|
||||
toastProps,
|
||||
isPaused,
|
||||
}: ToastContentProps<ToastData>) {
|
||||
const { container, closeBtn } = toastVariants({
|
||||
clickable: toastProps.closeOnClick || typeof toastProps.onClick !== 'undefined',
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={container()}>
|
||||
<ToastIcon isLoading={toastProps.isLoading} type={toastProps.type} />
|
||||
|
||||
<div className="flex flex-col">
|
||||
<h4 className="text-lg font-medium">{data.title}</h4>
|
||||
{data.message && <p className="text-sm text-fg-secondary">{data.message}</p>}
|
||||
</div>
|
||||
|
||||
<div className={closeBtn()}>
|
||||
<Button
|
||||
color="ghost"
|
||||
onClick={closeToast}
|
||||
size="icon"
|
||||
>
|
||||
<XCloseIcon />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="w-full h-1 absolute left-0 right-0 bottom-0">
|
||||
<ToastProgressBar closeToast={closeToast} isPaused={isPaused} toastProps={toastProps} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToastProgressBar({
|
||||
isPaused,
|
||||
toastProps,
|
||||
closeToast,
|
||||
}: {
|
||||
toastProps: ToastOptions;
|
||||
isPaused: boolean;
|
||||
closeToast: () => void;
|
||||
}) {
|
||||
const attrs: HTMLAttributes<HTMLDivElement> = {};
|
||||
|
||||
if (typeof toastProps.progress === 'number') {
|
||||
attrs.style = {
|
||||
transition: 'width 0.2s ease-in-out',
|
||||
width: `${toastProps.progress * 100}%`,
|
||||
};
|
||||
|
||||
if (toastProps.progress >= 1)
|
||||
attrs.onTransitionEnd = closeToast;
|
||||
}
|
||||
else {
|
||||
attrs.style = {
|
||||
animationName: styles['custom-anim-progress-bar'],
|
||||
animationDuration: `${toastProps.autoClose}ms`,
|
||||
animationPlayState: isPaused ? 'paused' : 'running',
|
||||
animationTimingFunction: 'linear',
|
||||
animationDelay: '500ms',
|
||||
};
|
||||
|
||||
attrs.onAnimationEnd = closeToast;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-1 bg-brand"
|
||||
{...attrs}
|
||||
>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Toasts() {
|
||||
// runs twice in dev mode because of react strict mode
|
||||
// the double notifications are NOT a bug!!!
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
const toastIngressMap: Map<string, ToastId> = new Map();
|
||||
|
||||
bindings.events.ingress.on((e) => {
|
||||
const title = typeof e.ingress_type === 'string' ? e.ingress_type : Object.keys(e.ingress_type).at(0) ?? 'Info';
|
||||
|
||||
const existingToastId = toastIngressMap.get(e.id);
|
||||
|
||||
if (existingToastId) {
|
||||
toastUpdate(existingToastId, {
|
||||
progress: e.percent ?? 1,
|
||||
isLoading: true,
|
||||
message: e.message,
|
||||
title,
|
||||
});
|
||||
}
|
||||
else {
|
||||
const toastId = toast({
|
||||
type: 'info',
|
||||
title,
|
||||
progress: e.percent ?? 1,
|
||||
isLoading: true,
|
||||
message: e.message,
|
||||
});
|
||||
|
||||
toastIngressMap.set(e.id, toastId);
|
||||
}
|
||||
}).then((fn) => {
|
||||
unlisten = fn;
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (unlisten)
|
||||
unlisten();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContainer
|
||||
newestOnTop
|
||||
position="bottom-right"
|
||||
transition={cssTransition({
|
||||
enter: 'animate-fade animate-duration-75',
|
||||
exit: 'animate-fade animate-duration-75 animate-reverse',
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ToastIcon({
|
||||
isLoading,
|
||||
type,
|
||||
}: {
|
||||
isLoading: boolean | undefined;
|
||||
type: string;
|
||||
}) {
|
||||
if (isLoading)
|
||||
return <Spinner size="extraSmall" />;
|
||||
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return <CheckCircleIcon className="w-6 h-6 text-success" />;
|
||||
case 'warning':
|
||||
return <AlertTriangleIcon className="w-6 h-6 text-code-warn" />;
|
||||
case 'error':
|
||||
return <AlertCircleIcon className="w-6 h-6 text-danger" />;
|
||||
case 'info':
|
||||
default:
|
||||
return <InfoCircleIcon className="w-6 h-6 text-code-info" />;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,3 +2,4 @@ export * from './AccountPopup';
|
|||
export * from './AddAccountModal';
|
||||
export * from './Overlay';
|
||||
export * from './Popup';
|
||||
export * from './Toasts';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { bindings } from '@/main';
|
||||
import useAppShellStore from '@/stores/appShellStore';
|
||||
import { useCommandSuspense } from '@onelauncher/common';
|
||||
import { useAsyncEffect, useCommandSuspense } from '@onelauncher/common';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useLastPlayedClusters() {
|
||||
return useCommandSuspense(
|
||||
|
|
@ -31,3 +33,41 @@ export function useActiveCluster() {
|
|||
|
||||
return clusters.find(c => c.id === clusterId) ?? clusters.at(0);
|
||||
}
|
||||
|
||||
export function useIsRunning(clusterId: number | undefined | null) {
|
||||
const [running, setRunning] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (!clusterId)
|
||||
return;
|
||||
|
||||
const query = queryClient.ensureQueryData({
|
||||
queryKey: ['isClusterRunning', clusterId],
|
||||
queryFn: () => bindings.core.isClusterRunning(clusterId),
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
});
|
||||
|
||||
query.then(setRunning);
|
||||
|
||||
return () => {
|
||||
setRunning(false);
|
||||
};
|
||||
}, [clusterId, queryClient]);
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
const unlisten = await bindings.events.process.on((e) => {
|
||||
if (e.cluster_id !== clusterId)
|
||||
return;
|
||||
|
||||
setRunning(e.kind.type !== 'Stopped');
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlisten();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return running;
|
||||
}
|
||||
|
|
|
|||
41
apps/oneclient/frontend/src/hooks/useLaunchCluster.ts
Normal file
41
apps/oneclient/frontend/src/hooks/useLaunchCluster.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { bindings } from '@/main';
|
||||
import { toast } from '@/utils/toast';
|
||||
import { getMessageFromError, isLauncherError } from '@onelauncher/common';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export function useLaunchCluster(): (clusterId: number | undefined | null) => void;
|
||||
export function useLaunchCluster(clusterId: number | undefined | null): () => void;
|
||||
|
||||
export function useLaunchCluster(clusterId?: number | undefined | null) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const launch = useCallback(
|
||||
async (id?: number | null) => {
|
||||
const targetId = clusterId ?? id; // prefer the one from hook arg
|
||||
|
||||
if (!targetId)
|
||||
return;
|
||||
|
||||
try {
|
||||
await bindings.core.launchCluster(targetId, null);
|
||||
|
||||
navigate({
|
||||
to: '/app/cluster/process',
|
||||
search: { clusterId: targetId },
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
if (isLauncherError(err))
|
||||
toast({
|
||||
type: 'error',
|
||||
title: 'Launch Error',
|
||||
message: getMessageFromError(err),
|
||||
});
|
||||
}
|
||||
},
|
||||
[clusterId, navigate],
|
||||
);
|
||||
|
||||
return launch;
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import type { QueryClient } from '@tanstack/react-query';
|
||||
import type { NavigateOptions, ToOptions } from '@tanstack/react-router';
|
||||
import { Toasts } from '@/components/overlay';
|
||||
import { TanStackDevtools } from '@tanstack/react-devtools';
|
||||
import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools';
|
||||
import { createRootRouteWithContext, Outlet, useRouter } from '@tanstack/react-router';
|
||||
|
|
@ -24,23 +25,27 @@ export const Route = createRootRouteWithContext<AppRouterContext>()({
|
|||
function RootRoute() {
|
||||
const router = useRouter();
|
||||
return (
|
||||
// <AnimatedOutletProvider>
|
||||
<RouterProvider
|
||||
navigate={(to, options) => router.navigate({ to, ...options })}
|
||||
useHref={to => router.buildLocation({ to }).href}
|
||||
>
|
||||
<DevTools />
|
||||
|
||||
<div className="h-screen flex flex-col overflow-hidden text-fg-primary">
|
||||
<Outlet />
|
||||
</div>
|
||||
|
||||
<Toasts />
|
||||
</RouterProvider>
|
||||
// </AnimatedOutletProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function DevTools() {
|
||||
return (
|
||||
<TanStackDevtools
|
||||
config={{
|
||||
position: 'top-left',
|
||||
}}
|
||||
plugins={[
|
||||
{
|
||||
name: 'Tanstack Query',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -51,7 +51,6 @@ function LogContent({
|
|||
}) {
|
||||
const { cluster } = Route.useRouteContext();
|
||||
|
||||
// TODO: Fix cache
|
||||
const { data: content, error, isLoading } = useCommand(
|
||||
['getLogByName', cluster.id, fileName],
|
||||
() => bindings.core.getLogByName(cluster.id, fileName),
|
||||
|
|
|
|||
76
apps/oneclient/frontend/src/routes/app/cluster/process.tsx
Normal file
76
apps/oneclient/frontend/src/routes/app/cluster/process.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import type { LogViewerRef } from '@/components/LogViewer';
|
||||
import { LoaderContainer, SheetPage, useSheetPageContext } from '@/components';
|
||||
import { LogViewer } from '@/components/LogViewer';
|
||||
import { bindings } from '@/main';
|
||||
import { getMessageFromError, useAsyncEffect, useCommand } from '@onelauncher/common';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { useRef } from 'react';
|
||||
|
||||
export const Route = createFileRoute('/app/cluster/process')({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { scrollContainerRef } = useSheetPageContext();
|
||||
|
||||
return (
|
||||
<SheetPage.Content>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="flex-1 text-xxl font-semibold">Game Log</h2>
|
||||
</div>
|
||||
|
||||
<LogContent scrollRef={scrollContainerRef} />
|
||||
</div>
|
||||
</SheetPage.Content>
|
||||
);
|
||||
}
|
||||
|
||||
function LogContent({
|
||||
scrollRef,
|
||||
}: {
|
||||
scrollRef: React.RefObject<HTMLElement | null>;
|
||||
}) {
|
||||
const { cluster } = Route.useRouteContext();
|
||||
const logViewerRef = useRef<LogViewerRef>(null);
|
||||
|
||||
const { data: content, error, isLoading, refetch } = useCommand(
|
||||
['getLogByName', cluster.id, 'latest.log'],
|
||||
() => bindings.core.getLogByName(cluster.id, 'latest.log'),
|
||||
{
|
||||
staleTime: 0,
|
||||
},
|
||||
);
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
const unlisten = await bindings.events.process.on((e) => {
|
||||
if (e.cluster_id !== cluster.id)
|
||||
return;
|
||||
|
||||
if (e.kind.type === 'Started') {
|
||||
refetch();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.kind.type !== 'Output')
|
||||
return;
|
||||
|
||||
logViewerRef.current?.push(e.kind.output);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlisten();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<LoaderContainer loading={isLoading}>
|
||||
<LogViewer
|
||||
autoScroll
|
||||
content={getMessageFromError(error) || content || 'Empty log file'}
|
||||
ref={logViewerRef}
|
||||
scrollRef={scrollRef}
|
||||
/>
|
||||
</LoaderContainer>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import { LoaderSuspense, Tab, TabList } from '@/components';
|
||||
import { LaunchButton } from '@/components/LaunchButton';
|
||||
import { SheetPage } from '@/components/SheetPage';
|
||||
import { useIsRunning } from '@/hooks/useClusters';
|
||||
import { bindings } from '@/main';
|
||||
import { prettifyLoader } from '@/utils/loaders';
|
||||
import { getVersionInfoOrDefault } from '@/utils/versionMap';
|
||||
|
|
@ -40,9 +42,12 @@ export const Route = createFileRoute('/app/cluster')({
|
|||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { cluster } = Route.useRouteContext();
|
||||
const tabListRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLElement>(null);
|
||||
|
||||
const isRunning = useIsRunning(cluster.id);
|
||||
|
||||
const { scrollYProgress } = useScroll({
|
||||
axis: 'y',
|
||||
container: scrollContainerRef,
|
||||
|
|
@ -68,6 +73,8 @@ function RouteComponent() {
|
|||
<Tab from={Route.id} search={search} to="/app/cluster/overview">Overview</Tab>
|
||||
<Tab from={Route.id} search={search} to="/app/cluster/logs">Logs</Tab>
|
||||
<Tab from={Route.id} search={{ ...search, packageType: 'mod' }} to="/app/cluster/browser">Browser</Tab>
|
||||
{isRunning && <span className="flex-1"></span>}
|
||||
{isRunning && <Tab from={Route.id} search={search} to="/app/cluster/process">Process</Tab>}
|
||||
</TabList>
|
||||
|
||||
<div className="relative pb-8">
|
||||
|
|
@ -85,8 +92,6 @@ function HeaderLarge() {
|
|||
|
||||
const openFolder = () => bindings.folders.openCluster(cluster.folder_name);
|
||||
|
||||
const launch = () => bindings.core.launchCluster(cluster.id, null);
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-end gap-16">
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
|
|
@ -110,13 +115,10 @@ function HeaderLarge() {
|
|||
<FolderIcon />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
color="primary"
|
||||
onPress={launch}
|
||||
<LaunchButton
|
||||
clusterId={cluster.id}
|
||||
size="large"
|
||||
>
|
||||
Launch
|
||||
</Button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -127,8 +129,6 @@ function HeaderSmall() {
|
|||
|
||||
const openFolder = () => bindings.folders.openCluster(cluster.folder_name);
|
||||
|
||||
const launch = () => bindings.core.launchCluster(cluster.id, null);
|
||||
|
||||
return (
|
||||
<div className="flex flex-row justify-between items-center h-full">
|
||||
<h1 className="text-2lg h-full font-medium">{prettifyLoader(cluster.mc_loader)} {cluster.mc_version}</h1>
|
||||
|
|
@ -142,12 +142,10 @@ function HeaderSmall() {
|
|||
<FolderIcon />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
<LaunchButton
|
||||
clusterId={cluster.id}
|
||||
color="primary"
|
||||
onPress={launch}
|
||||
>
|
||||
Launch
|
||||
</Button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { GameLoader } from '@/bindings.gen';
|
||||
import { GameBackground } from '@/components';
|
||||
import { LaunchButton } from '@/components/LaunchButton';
|
||||
import { SheetPage } from '@/components/SheetPage';
|
||||
import { bindings } from '@/main';
|
||||
import useClusterStore from '@/stores/clusterStore';
|
||||
|
|
@ -92,6 +93,18 @@ function RouteComponent() {
|
|||
|
||||
const versionInfo = useMemo(() => getVersionInfoOrDefault(cluster?.mc_version), [cluster]);
|
||||
|
||||
const view = useCallback(() => {
|
||||
if (!cluster)
|
||||
return;
|
||||
|
||||
navigate({
|
||||
to: `/app/cluster/overview`,
|
||||
search: {
|
||||
clusterId: cluster.id,
|
||||
},
|
||||
});
|
||||
}, [cluster, navigate]);
|
||||
|
||||
return (
|
||||
<SheetPage
|
||||
headerLarge={<HeaderLarge />}
|
||||
|
|
@ -183,21 +196,12 @@ function RouteComponent() {
|
|||
)}
|
||||
|
||||
<div className="w-full flex flex-row gap-4 mt-2">
|
||||
<Button className="flex-1" size="large">Launch</Button>
|
||||
<LaunchButton className="flex-1" clusterId={cluster?.id} size="large" />
|
||||
|
||||
<Button
|
||||
className="flex-1"
|
||||
color="secondary"
|
||||
onPress={() => {
|
||||
if (!cluster)
|
||||
return;
|
||||
|
||||
navigate({
|
||||
to: `/app/cluster/overview`,
|
||||
search: {
|
||||
clusterId: cluster.id,
|
||||
},
|
||||
});
|
||||
}}
|
||||
onPress={view}
|
||||
size="large"
|
||||
>
|
||||
View
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import type { ClusterModel, GameLoader } from '@/bindings.gen';
|
||||
import type { ButtonProps } from 'react-aria-components';
|
||||
import { GameBackground } from '@/components';
|
||||
import { LaunchButton } from '@/components/LaunchButton';
|
||||
import { useActiveCluster, useLastPlayedClusters } from '@/hooks/useClusters';
|
||||
import { bindings } from '@/main';
|
||||
import useAppShellStore from '@/stores/appShellStore';
|
||||
import { prettifyLoader } from '@/utils/loaders';
|
||||
import { animations, transitions } from '@/utils/motion';
|
||||
import { animations } from '@/utils/motion';
|
||||
import { getVersionInfo, getVersionInfoOrDefault } from '@/utils/versionMap';
|
||||
import { Button } from '@onelauncher/common/components';
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||
|
|
@ -65,13 +65,6 @@ function ActiveClusterInfo({
|
|||
from: Route.id,
|
||||
});
|
||||
|
||||
const launch = () => {
|
||||
if (!cluster)
|
||||
return;
|
||||
|
||||
bindings.core.launchCluster(cluster.id, null);
|
||||
};
|
||||
|
||||
const viewCluster = () => {
|
||||
if (!cluster)
|
||||
return;
|
||||
|
|
@ -86,16 +79,24 @@ function ActiveClusterInfo({
|
|||
|
||||
return (
|
||||
<motion.div
|
||||
{...animations.slideInLeft}
|
||||
animate={{
|
||||
position: 'relative',
|
||||
left: '0',
|
||||
}}
|
||||
className="flex flex-1 flex-col justify-center items-start gap-2"
|
||||
initial={{
|
||||
position: 'relative',
|
||||
left: '-50%',
|
||||
}}
|
||||
key={(cluster?.mc_version ?? Math.random()) + (cluster?.mc_loader ?? '')}
|
||||
transition={{ ...transitions.spring, delay: 0.2 }}
|
||||
transition={{ ease: 'backInOut', duration: 0.7 }}
|
||||
>
|
||||
<h1 className="text-6xl font-bold text-fg-primary">{cluster?.mc_version} {prettifyLoader(cluster?.mc_loader ?? 'vanilla')}</h1>
|
||||
<p className="text-lg font-medium text-fg-secondary">{versionInfo.shortDescription}</p>
|
||||
|
||||
<div className="flex flex-row justify-center items-center gap-2">
|
||||
<Button onPress={launch} size="large">Launch</Button>
|
||||
<LaunchButton clusterId={cluster?.id} size="large" />
|
||||
|
||||
<Button color="ghost" onPress={viewCluster} size="iconLarge">
|
||||
<Settings04Icon />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,4 @@
|
|||
@layer utilities {
|
||||
@keyframes custom-anim-fade-scale {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes custom-anim-zoom {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
|
|
@ -20,12 +9,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
@utility animate-fade-scale {
|
||||
transform: scale(0);
|
||||
opacity: 1;
|
||||
animation: custom-anim-fade-scale var(--default-animation-duration, 1s) var(--default-animation-timing-function, cubic-bezier(0, 0, 0.2, 1)) var(--default-animation-delay, 0s) forwards;
|
||||
}
|
||||
|
||||
@utility animate-zoom {
|
||||
transform: scale(0);
|
||||
animation: custom-anim-zoom var(--default-animation-duration, 1s) var(--default-animation-timing-function, cubic-bezier(0, 0, 0.2, 1)) var(--default-animation-delay, 0s) forwards;
|
||||
|
|
|
|||
62
apps/oneclient/frontend/src/utils/toast.ts
Normal file
62
apps/oneclient/frontend/src/utils/toast.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import type { UpdateOptions } from 'react-toastify';
|
||||
import { Toast } from '@/components/overlay';
|
||||
import { toast as toastify } from 'react-toastify';
|
||||
|
||||
export type ToastId = string | number;
|
||||
|
||||
export interface ToastData {
|
||||
type: 'info' | 'success' | 'warning' | 'error';
|
||||
title: string;
|
||||
message?: string | undefined;
|
||||
}
|
||||
|
||||
export interface ToastOptions extends ToastData {
|
||||
progress?: number;
|
||||
isLoading?: boolean | undefined;
|
||||
autoClose?: number | false | undefined;
|
||||
}
|
||||
|
||||
export function toast({
|
||||
progress,
|
||||
isLoading,
|
||||
autoClose = 5000,
|
||||
...data
|
||||
}: ToastOptions): ToastId {
|
||||
return toastify(Toast, {
|
||||
data,
|
||||
progress,
|
||||
isLoading,
|
||||
autoClose,
|
||||
closeOnClick: false,
|
||||
closeButton: false,
|
||||
hideProgressBar: true,
|
||||
icon: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function toastUpdate(
|
||||
toastId: ToastId,
|
||||
{
|
||||
progress,
|
||||
isLoading,
|
||||
autoClose,
|
||||
...data
|
||||
}: Partial<ToastOptions>,
|
||||
) {
|
||||
const objData: UpdateOptions<Record<string, unknown>> = {
|
||||
progress,
|
||||
isLoading,
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries<unknown>(data)) {
|
||||
if (value === undefined || value === null)
|
||||
continue;
|
||||
|
||||
if (!objData.data)
|
||||
objData.data = {};
|
||||
|
||||
objData.data[key] = value;
|
||||
}
|
||||
|
||||
toastify.update(toastId, objData);
|
||||
}
|
||||
|
|
@ -1,13 +1,82 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use serde::Serialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::api::cluster::dao::ClusterId;
|
||||
use crate::error::LauncherResult;
|
||||
use crate::store::State;
|
||||
use crate::store::processes::Process;
|
||||
|
||||
#[onelauncher_macro::specta]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum ProcessPayload {
|
||||
Starting { command: String },
|
||||
Started { process: Process },
|
||||
Stopped { pid: u32, exit_code: i32 },
|
||||
Output { pid: u32, output: String },
|
||||
pub struct ProcessPayload {
|
||||
pub cluster_id: ClusterId,
|
||||
pub kind: ProcessPayloadKind,
|
||||
}
|
||||
|
||||
#[onelauncher_macro::specta]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum ProcessPayloadKind {
|
||||
Starting {
|
||||
command: String,
|
||||
},
|
||||
Started {
|
||||
pid: u32,
|
||||
started_at: DateTime<Utc>,
|
||||
post_hook: Option<String>,
|
||||
account_id: Uuid,
|
||||
},
|
||||
Stopped {
|
||||
pid: u32,
|
||||
exit_code: i32,
|
||||
},
|
||||
Output {
|
||||
pid: u32,
|
||||
output: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub async fn get_running_processes() -> LauncherResult<Vec<Process>> {
|
||||
let state = State::get().await?;
|
||||
let running = state.processes.get_running().await;
|
||||
|
||||
let processes = futures::future::join_all(
|
||||
running
|
||||
.into_iter()
|
||||
.map(|process| async move { process.read().await.clone() }),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(processes)
|
||||
}
|
||||
|
||||
pub async fn get_running_processes_by_cluster_id(
|
||||
cluster_id: ClusterId,
|
||||
) -> LauncherResult<Vec<Process>> {
|
||||
let state = State::get().await?;
|
||||
let running = state.processes.get_running_by_cluster(cluster_id).await;
|
||||
|
||||
let processes = futures::future::join_all(
|
||||
running
|
||||
.into_iter()
|
||||
.map(|process| async move { process.read().await.clone() }),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(processes)
|
||||
}
|
||||
|
||||
pub async fn is_cluster_running(cluster_id: ClusterId) -> LauncherResult<bool> {
|
||||
let state = State::get().await?;
|
||||
Ok(state.processes.has_running(cluster_id).await)
|
||||
}
|
||||
|
||||
pub async fn kill_process(pid: u32) -> LauncherResult<()> {
|
||||
let state = State::get().await?;
|
||||
if let Some(process) = state.processes.get(pid).await {
|
||||
process.read().await.child_type.write().await.kill().await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ use crate::api::packages::data::{
|
|||
SearchResult,
|
||||
};
|
||||
use crate::api::packages::provider::ProviderExt;
|
||||
use crate::store::processes::Process;
|
||||
use crate::store::{Settings, State};
|
||||
use crate::utils::pagination::Paginated;
|
||||
use interpulse::api::minecraft::Version;
|
||||
|
|
@ -55,6 +56,21 @@ pub trait TauriLauncherApi {
|
|||
#[taurpc(alias = "getLogByName")]
|
||||
async fn get_log_by_name(id: ClusterId, name: String) -> LauncherResult<Option<String>>;
|
||||
|
||||
// MARK: API: processes
|
||||
#[taurpc(alias = "getRunningProcesses")]
|
||||
async fn get_running_processes() -> LauncherResult<Vec<Process>>;
|
||||
|
||||
#[taurpc(alias = "getRunningProcessesByClusterId")]
|
||||
async fn get_running_processes_by_cluster_id(
|
||||
cluster_id: ClusterId,
|
||||
) -> LauncherResult<Vec<Process>>;
|
||||
|
||||
#[taurpc(alias = "isClusterRunning")]
|
||||
async fn is_cluster_running(cluster_id: ClusterId) -> LauncherResult<bool>;
|
||||
|
||||
#[taurpc(alias = "killProcess")]
|
||||
async fn kill_process(pid: u32) -> LauncherResult<()>;
|
||||
|
||||
// MARK: API: profiles
|
||||
#[taurpc(alias = "getProfileOrDefault")]
|
||||
async fn get_profile_or_default(name: Option<String>) -> LauncherResult<SettingsProfile>;
|
||||
|
|
@ -160,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)]
|
||||
|
|
@ -322,6 +341,26 @@ impl TauriLauncherApi for TauriLauncherApiImpl {
|
|||
api::cluster::content::get_log_by_name(&cluster, &name).await
|
||||
}
|
||||
|
||||
// MARK: Impl: processes
|
||||
async fn get_running_processes(self) -> LauncherResult<Vec<Process>> {
|
||||
api::processes::get_running_processes().await
|
||||
}
|
||||
|
||||
async fn get_running_processes_by_cluster_id(
|
||||
self,
|
||||
cluster_id: ClusterId,
|
||||
) -> LauncherResult<Vec<Process>> {
|
||||
api::processes::get_running_processes_by_cluster_id(cluster_id).await
|
||||
}
|
||||
|
||||
async fn is_cluster_running(self, cluster_id: ClusterId) -> LauncherResult<bool> {
|
||||
api::processes::is_cluster_running(cluster_id).await
|
||||
}
|
||||
|
||||
async fn kill_process(self, pid: u32) -> LauncherResult<()> {
|
||||
api::processes::kill_process(pid).await
|
||||
}
|
||||
|
||||
// MARK: Impl: profiles
|
||||
async fn get_global_profile(self) -> SettingsProfile {
|
||||
api::setting_profiles::get_global_profile().await
|
||||
|
|
@ -581,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(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ use uuid::Uuid;
|
|||
use crate::api::cluster;
|
||||
use crate::api::cluster::dao::ClusterId;
|
||||
use crate::api::game::log::{CensorMap, censor_line};
|
||||
use crate::api::processes::ProcessPayload;
|
||||
use crate::api::processes::{ProcessPayload, ProcessPayloadKind};
|
||||
use crate::api::proxy::event::{LauncherEvent, send_event};
|
||||
use crate::error::LauncherResult;
|
||||
use crate::send_error;
|
||||
|
|
@ -176,8 +176,11 @@ impl ProcessStore {
|
|||
settings: &setting_profiles::Model,
|
||||
mut command: Command,
|
||||
) -> LauncherResult<Arc<RwLock<Process>>> {
|
||||
send_event(LauncherEvent::Process(ProcessPayload::Starting {
|
||||
command: format!("{command:?}"),
|
||||
send_event(LauncherEvent::Process(ProcessPayload {
|
||||
cluster_id,
|
||||
kind: ProcessPayloadKind::Starting {
|
||||
command: format!("{command:?}"),
|
||||
},
|
||||
}))
|
||||
.await;
|
||||
|
||||
|
|
@ -224,9 +227,9 @@ impl ProcessStore {
|
|||
} {
|
||||
censor_line(&censors, &mut line);
|
||||
|
||||
send_event(LauncherEvent::Process(ProcessPayload::Output {
|
||||
pid,
|
||||
output: line,
|
||||
send_event(LauncherEvent::Process(ProcessPayload {
|
||||
cluster_id,
|
||||
kind: ProcessPayloadKind::Output { pid, output: line },
|
||||
}))
|
||||
.await;
|
||||
}
|
||||
|
|
@ -251,6 +254,18 @@ impl ProcessStore {
|
|||
let process = Arc::new(RwLock::new(process));
|
||||
let mut write_guard = self.processes.write().await;
|
||||
write_guard.insert(pid, process.clone());
|
||||
|
||||
send_event(LauncherEvent::Process(ProcessPayload {
|
||||
cluster_id,
|
||||
kind: ProcessPayloadKind::Started {
|
||||
pid,
|
||||
started_at,
|
||||
post_hook: settings.hook_post.clone(),
|
||||
account_id: creds.id,
|
||||
},
|
||||
}))
|
||||
.await;
|
||||
|
||||
tracing::info!("spawned process with pid {}", pid);
|
||||
|
||||
Ok(process)
|
||||
|
|
@ -285,9 +300,9 @@ impl ProcessStore {
|
|||
send_error!("{err:?}");
|
||||
}
|
||||
|
||||
send_event(LauncherEvent::Process(ProcessPayload::Stopped {
|
||||
pid,
|
||||
exit_code,
|
||||
send_event(LauncherEvent::Process(ProcessPayload {
|
||||
cluster_id,
|
||||
kind: ProcessPayloadKind::Stopped { pid, exit_code },
|
||||
}))
|
||||
.await;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
export * from './useAsyncEffect';
|
||||
export * from './useCommand';
|
||||
export * from './usePagination';
|
||||
|
|
|
|||
19
packages/web_common/src/hooks/useAsyncEffect.ts
Normal file
19
packages/web_common/src/hooks/useAsyncEffect.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type React from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
type UnlistenFn = (() => any) | undefined;
|
||||
|
||||
export function useAsyncEffect(cb: () => Promise<UnlistenFn>, deps?: React.DependencyList) {
|
||||
useEffect(() => {
|
||||
let unlisten: UnlistenFn;
|
||||
|
||||
cb().then((fn) => {
|
||||
unlisten = fn;
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlisten?.();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- the function should not depend on the cb but the deps list
|
||||
}, deps);
|
||||
}
|
||||
|
|
@ -23,7 +23,7 @@
|
|||
--color-brand: #2b4bff;
|
||||
--color-brand-hover: #2843dd;
|
||||
--color-brand-pressed: #3957ff;
|
||||
--color-brand-disabled: #2543eb;
|
||||
--color-brand-disabled: #1f2f81;
|
||||
|
||||
--color-component-bg: #1a2228;
|
||||
--color-component-bg-hover: #1d242b;
|
||||
|
|
|
|||
18
pnpm-lock.yaml
generated
18
pnpm-lock.yaml
generated
|
|
@ -105,6 +105,9 @@ catalogs:
|
|||
react-just-parallax:
|
||||
specifier: ^3.1.16
|
||||
version: 3.1.16
|
||||
react-toastify:
|
||||
specifier: ^11.0.5
|
||||
version: 11.0.5
|
||||
rimraf:
|
||||
specifier: ^6.0.1
|
||||
version: 6.0.1
|
||||
|
|
@ -301,6 +304,9 @@ importers:
|
|||
react-just-parallax:
|
||||
specifier: 'catalog:'
|
||||
version: 3.1.16(react@19.1.0)
|
||||
react-toastify:
|
||||
specifier: 'catalog:'
|
||||
version: 11.0.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
react-markdown:
|
||||
specifier: ^10.1.0
|
||||
version: 10.1.0(@types/react@19.1.2)(react@19.1.0)
|
||||
|
|
@ -5224,6 +5230,12 @@ packages:
|
|||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
|
||||
|
||||
react-toastify@11.0.5:
|
||||
resolution: {integrity: sha512-EpqHBGvnSTtHYhCPLxML05NLY2ZX0JURbAdNYa6BUkk+amz4wbKBQvoKQAB0ardvSarUBuY4Q4s1sluAzZwkmA==}
|
||||
peerDependencies:
|
||||
react: ^18 || ^19
|
||||
react-dom: ^18 || ^19
|
||||
|
||||
react@19.1.0:
|
||||
resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -11686,6 +11698,12 @@ snapshots:
|
|||
'@react-types/shared': 3.29.0(react@19.1.0)
|
||||
react: 19.1.0
|
||||
|
||||
react-toastify@11.0.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
|
||||
react@19.1.0: {}
|
||||
|
||||
read-pkg-up@7.0.1:
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ catalog:
|
|||
'@untitled-theme/icons-react': ^0.14.0
|
||||
motion: ^12.10.5
|
||||
react-dom: ^19.0.0
|
||||
react-toastify: ^11.0.5
|
||||
react-just-parallax: ^3.1.16
|
||||
tailwind-merge: ^3.2.0
|
||||
tailwind-variants: ^1.0.0
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue