🔨 chore: merge with main
This commit is contained in:
commit
7f9c0b4d05
28 changed files with 671 additions and 352 deletions
|
|
@ -107,7 +107,7 @@ pub async fn remove_cluster(uuid: Uuid) -> Result<(), String> {
|
|||
#[tauri::command]
|
||||
pub async fn run_cluster(uuid: Uuid) -> Result<(Uuid, u32), String> {
|
||||
let path = ClusterPath::find_by_uuid(uuid).await?;
|
||||
let c_lock = cluster::run(&path).await?;
|
||||
let c_lock = cluster::run_default(&path).await?;
|
||||
|
||||
let p_uuid = c_lock.read().await.uuid;
|
||||
let p_pid = c_lock
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ macro_rules! collect_commands {
|
|||
get_running_clusters,
|
||||
get_processes_by_path,
|
||||
kill_process,
|
||||
is_cluster_running,
|
||||
get_pid_by_uuid,
|
||||
// Settings
|
||||
get_settings,
|
||||
set_settings,
|
||||
|
|
|
|||
|
|
@ -8,12 +8,24 @@ pub async fn get_running_clusters() -> Result<Vec<Cluster>, String> {
|
|||
Ok(processor::get_running_clusters().await?)
|
||||
}
|
||||
|
||||
#[specta::specta]
|
||||
#[tauri::command]
|
||||
pub async fn is_cluster_running(uuid: Uuid) -> Result<bool, String> {
|
||||
Ok(processor::is_cluster_running(uuid).await?)
|
||||
}
|
||||
|
||||
#[specta::specta]
|
||||
#[tauri::command]
|
||||
pub async fn get_processes_by_path(path: ClusterPath) -> Result<Vec<Uuid>, String> {
|
||||
Ok(processor::get_uuids_by_cluster_path(path).await?)
|
||||
}
|
||||
|
||||
#[specta::specta]
|
||||
#[tauri::command]
|
||||
pub async fn get_pid_by_uuid(uuid: Uuid) -> Result<u32, String> {
|
||||
Ok(processor::get_pid_by_uuid(uuid).await?)
|
||||
}
|
||||
|
||||
#[specta::specta]
|
||||
#[tauri::command]
|
||||
pub async fn kill_process(uuid: Uuid) -> Result<(), String> {
|
||||
|
|
|
|||
|
|
@ -3,17 +3,24 @@ body {
|
|||
}
|
||||
|
||||
/* start lynith's native experience (tm) */
|
||||
* {
|
||||
* {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
user-drag: none;
|
||||
cursor: default !important;
|
||||
cursor: default !important;
|
||||
animation-duration: 150ms !important;
|
||||
}
|
||||
|
||||
*:hover {
|
||||
cursor: default !important;
|
||||
*:hover {
|
||||
cursor: default !important;
|
||||
}
|
||||
|
||||
img {
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
user-drag: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* end lynith's native experience (tm) */
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { type ParentProps, mergeProps, splitProps } from 'solid-js';
|
||||
import { type ParentProps, Show, mergeProps, splitProps } from 'solid-js';
|
||||
import { Transition, type TransitionProps } from 'solid-transition-group';
|
||||
import useSettingsContext from '~ui/hooks/useSettings';
|
||||
|
||||
function AnimatedRoutes(props: TransitionProps & ParentProps) {
|
||||
const settings = useSettingsContext();
|
||||
|
||||
const defaultProps: TransitionProps = {
|
||||
mode: 'outin',
|
||||
enterClass: 'page-animation-enter',
|
||||
|
|
@ -16,9 +19,17 @@ function AnimatedRoutes(props: TransitionProps & ParentProps) {
|
|||
const merged = mergeProps(defaultProps, rest);
|
||||
|
||||
return (
|
||||
<Transition {...merged}>
|
||||
{split.children}
|
||||
</Transition>
|
||||
<Show
|
||||
when={settings.disable_animations !== true}
|
||||
fallback={(
|
||||
<>{split.children}</>
|
||||
)}
|
||||
children={(
|
||||
<Transition {...merged}>
|
||||
{split.children}
|
||||
</Transition>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
53
apps/frontend/src/ui/components/DynamicTime.tsx
Normal file
53
apps/frontend/src/ui/components/DynamicTime.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { type ParentProps, createSignal, onCleanup, onMount } from 'solid-js';
|
||||
|
||||
type TimeProps = ParentProps & {
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
export function TimeAgo(props: TimeProps) {
|
||||
const formatter = new Intl.RelativeTimeFormat(navigator.language, { numeric: 'auto' });
|
||||
const units = {
|
||||
year: 24 * 60 * 60 * 1000 * 365,
|
||||
month: 24 * 60 * 60 * 1000 * 365 / 12,
|
||||
day: 24 * 60 * 60 * 1000,
|
||||
hour: 60 * 60 * 1000,
|
||||
minute: 60 * 1000,
|
||||
second: 1000,
|
||||
};
|
||||
|
||||
const getRelativeTime = (timestamp: number): string => {
|
||||
const elapsed = timestamp - Date.now();
|
||||
|
||||
for (const [unit, ms] of Object.entries(units))
|
||||
if (Math.abs(elapsed) > ms || unit === 'second')
|
||||
return formatter.format(Math.round(elapsed / ms), unit as Intl.RelativeTimeFormatUnit);
|
||||
|
||||
return 'now';
|
||||
};
|
||||
|
||||
return <InternalTime {...props} format={getRelativeTime} />;
|
||||
}
|
||||
|
||||
type InternalTimeProps = TimeProps & {
|
||||
format: (time: number) => string;
|
||||
};
|
||||
|
||||
function InternalTime(props: InternalTimeProps) {
|
||||
const [time, setTime] = createSignal('');
|
||||
const [intervalId, setIntervalId] = createSignal<NodeJS.Timeout | undefined>(undefined);
|
||||
|
||||
onMount(() => {
|
||||
const loop = () => {
|
||||
setTime(props.format(props.timestamp));
|
||||
};
|
||||
|
||||
loop();
|
||||
setIntervalId(setInterval(loop, 1000));
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
clearInterval(intervalId()!);
|
||||
});
|
||||
|
||||
return <span>{time()}</span>;
|
||||
}
|
||||
|
|
@ -4,14 +4,17 @@ type SettingsRowProps = ParentProps & {
|
|||
title: JSX.Element;
|
||||
description: JSX.Element;
|
||||
icon: JSX.Element;
|
||||
onClick?: () => any;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
function SettingsRow(props: SettingsRowProps) {
|
||||
return (
|
||||
<div
|
||||
class={`flex flex-row bg-component-bg rounded-xl gap-3.5 p-4 ${props.onClick ? 'hover:bg-component-bg-hover active:bg-component-bg-pressed' : ''} items-center`}
|
||||
{...(props.onClick ? { onClick: props.onClick } : {})}
|
||||
class="flex flex-row bg-component-bg rounded-xl gap-3.5 p-4 items-center"
|
||||
classList={{
|
||||
'bg-component-bg-disabled': props.disabled,
|
||||
'text-fg-primary-disabled': props.disabled,
|
||||
}}
|
||||
>
|
||||
<div class="flex justify-center items-center h-8 w-8">
|
||||
{props.icon}
|
||||
|
|
@ -22,7 +25,7 @@ function SettingsRow(props: SettingsRowProps) {
|
|||
<p class="text-wrap text-sm">{props.description}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row justify-center items-center gap-2">
|
||||
<div class="flex h-9 flex-row justify-center items-center gap-2">
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { For, createEffect, splitProps } from 'solid-js';
|
|||
|
||||
type SidebarProps = ParentProps & {
|
||||
base: string;
|
||||
links: Record<string, ([JSX.Element, string, string] | undefined)[]>;
|
||||
links: Record<string, ([JSX.Element, string, string, URLSearchParams?] | undefined)[]>;
|
||||
};
|
||||
|
||||
function Sidebar(props: SidebarProps) {
|
||||
|
|
@ -16,8 +16,13 @@ function Sidebar(props: SidebarProps) {
|
|||
throw new Error('Base should not end with a slash');
|
||||
});
|
||||
|
||||
function goto(href: string) {
|
||||
const url = `${props.base}${href}${location.search.toString()}`;
|
||||
function goto(href: string, params?: URLSearchParams) {
|
||||
const currParams = new URLSearchParams(location.search);
|
||||
if (params)
|
||||
for (const [key, value] of params)
|
||||
currParams.set(key, value);
|
||||
|
||||
const url = `${props.base}${href}?${currParams.toString()}`;
|
||||
navigate(url);
|
||||
}
|
||||
|
||||
|
|
@ -40,7 +45,7 @@ function Sidebar(props: SidebarProps) {
|
|||
|
||||
return (
|
||||
<a
|
||||
onClick={() => goto(link[2])}
|
||||
onClick={() => goto(link[2], link[3])}
|
||||
class={
|
||||
`px-3 py-1 rounded-md text-md hover:bg-component-bg-hover active:bg-component-bg-pressed ${isActive(link[2]) ? 'bg-gray-05' : ''}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,47 +0,0 @@
|
|||
import { type ParentProps, createSignal, onCleanup, onMount } from 'solid-js';
|
||||
|
||||
type TimeAgoProps = ParentProps & {
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
function TimeAgo(props: TimeAgoProps) {
|
||||
const [time, setTime] = createSignal('');
|
||||
const [intervalId, setIntervalId] = createSignal<NodeJS.Timeout | undefined>(undefined);
|
||||
|
||||
onMount(() => {
|
||||
const formatter = new Intl.RelativeTimeFormat(navigator.language, { numeric: 'auto' });
|
||||
const units = {
|
||||
year: 24 * 60 * 60 * 1000 * 365,
|
||||
month: 24 * 60 * 60 * 1000 * 365 / 12,
|
||||
day: 24 * 60 * 60 * 1000,
|
||||
hour: 60 * 60 * 1000,
|
||||
minute: 60 * 1000,
|
||||
second: 1000,
|
||||
};
|
||||
|
||||
const getRelativeTime = (timestamp: number): string => {
|
||||
const elapsed = timestamp - Date.now();
|
||||
|
||||
for (const [unit, ms] of Object.entries(units))
|
||||
if (Math.abs(elapsed) > ms || unit === 'second')
|
||||
return formatter.format(Math.round(elapsed / ms), unit as Intl.RelativeTimeFormatUnit);
|
||||
|
||||
return 'now';
|
||||
};
|
||||
|
||||
const loop = () => {
|
||||
setTime(getRelativeTime(props.timestamp));
|
||||
};
|
||||
|
||||
loop();
|
||||
setIntervalId(setInterval(loop, 1000));
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
clearInterval(intervalId()!);
|
||||
});
|
||||
|
||||
return <span>{time()}</span>;
|
||||
}
|
||||
|
||||
export default TimeAgo;
|
||||
|
|
@ -1,26 +1,19 @@
|
|||
.button {
|
||||
@apply py-1.5 px-3 rounded-lg flex flex-row justify-center items-center gap-x-2.5;
|
||||
|
||||
&:disabled {
|
||||
@apply border-op-1! bg-opacity-10! text-op-10! pointer-events-none;
|
||||
|
||||
svg {
|
||||
@apply fill-op-10! stroke-op-10!;
|
||||
}
|
||||
}
|
||||
|
||||
&__primary {
|
||||
@apply text-onbrand hover:text-onbrand-hover active:text-onbrand-pressed disabled:text-onbrand-disabled;
|
||||
@apply bg-brand hover:bg-brand-hover active:bg-brand-pressed disabled:bg-brand-disabled;
|
||||
}
|
||||
|
||||
&__secondary, &__iconSecondary {
|
||||
@apply border border-gray-05;
|
||||
@apply border box-border border-gray-05;
|
||||
@apply text-fg-primary hover:text-fg-primary-hover active:text-fg-primary-pressed disabled:text-fg-primary-disabled;
|
||||
@apply bg-component-bg hover:bg-component-bg-hover active:bg-component-bg-pressed disabled:bg-component-bg-disabled;
|
||||
}
|
||||
|
||||
&__danger, &__iconDanger {
|
||||
@apply text-onbrand hover:text-onbrand-hover active:text-onbrand-pressed disabled:text-onbrand-disabled;
|
||||
@apply bg-danger hover:bg-danger-hover active:bg-danger-pressed disabled:bg-danger-disabled;
|
||||
}
|
||||
|
||||
|
|
@ -62,6 +55,14 @@
|
|||
@apply stroke-fg-primary-disabled;
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
@apply border-opacity-1! bg-opacity-10! text-opacity-10! pointer-events-none;
|
||||
|
||||
svg {
|
||||
@apply fill-opacity-10! stroke-opacity-10!;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.icon > * {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ function Toggle(props: ToggleProps) {
|
|||
return (
|
||||
<div
|
||||
onClick={() => toggle()}
|
||||
class={`w-[40px] h-[22px] p-3 flex flex-row relative rounded-full transition-colors overflow-hidden ${checked() ? 'bg-brand' : 'bg-gray-10'}${` ${split.class}` ?? ''}`}
|
||||
class={`w-[40px] h-[22px] p-3 flex flex-row relative rounded-full transition-colors overflow-hidden ${checked() ? 'bg-brand' : 'bg-gray-10'}${` ${split.class}` || ''}`}
|
||||
{...rest}
|
||||
>
|
||||
<div class={`w-[16px] h-[16px] rounded-full mx-1 left-0 top-1/2 -translate-y-1/2 transition-transform bg-white absolute ${checked() ? 'translate-x-full' : 'translate-x-0'}`} />
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { For, createMemo } from 'solid-js';
|
||||
import { OverlayScrollbarsComponent } from 'overlayscrollbars-solid';
|
||||
import styles from './FormattedLog.module.scss';
|
||||
|
||||
interface FormattedLogProps {
|
||||
|
|
@ -9,11 +10,15 @@ function FormattedLog(props: FormattedLogProps) {
|
|||
const lines = createMemo(() => props.log.split('\n'));
|
||||
|
||||
return (
|
||||
<code class={styles.log}>
|
||||
<For each={lines()}>
|
||||
{line => <Line line={line} />}
|
||||
</For>
|
||||
</code>
|
||||
<div class="bg-component-bg flex flex-1 h-full font-mono font-medium overflow-auto p-2 rounded-md mt-2">
|
||||
<OverlayScrollbarsComponent class="flex-1 h-full relative">
|
||||
<code class={styles.log}>
|
||||
<For each={lines()}>
|
||||
{line => <Line line={line} />}
|
||||
</For>
|
||||
</code>
|
||||
</OverlayScrollbarsComponent>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
.linearBlur::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
-webkit-backdrop-filter: blur(var(--blur));
|
||||
backdrop-filter: blur(var(--blur));
|
||||
|
||||
-webkit-mask: linear-gradient(var(--degrees), transparent, black 50%);
|
||||
mask: linear-gradient(var(--degrees), transparent, black 50%);
|
||||
|
||||
background: linear-gradient(var(--degrees), transparent, rgba(0, 0, 0, 0.4) 50%);
|
||||
}
|
||||
|
|
@ -1,21 +1,28 @@
|
|||
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
import { type JSX, splitProps } from 'solid-js';
|
||||
import { type JSX, type ParentProps, Show, splitProps } from 'solid-js';
|
||||
import styles from './ClusterCover.module.scss';
|
||||
import defaultCover from '~assets/images/default_instance_cover.jpg';
|
||||
import type { Cluster } from '~bindings';
|
||||
|
||||
type ClusterCoverProps = JSX.HTMLAttributes<HTMLImageElement> & {
|
||||
cluster: Cluster | undefined;
|
||||
override?: string;
|
||||
fallback?: string;
|
||||
linearBlur?: {
|
||||
degrees?: number;
|
||||
blur?: number;
|
||||
class?: string;
|
||||
};
|
||||
};
|
||||
|
||||
function ClusterCover(props: ClusterCoverProps) {
|
||||
const [split, rest] = splitProps(props, ['cluster', 'override', 'onError']);
|
||||
const [split, rest] = splitProps(props, ['cluster', 'override', 'onError', 'fallback', 'linearBlur', 'class']);
|
||||
|
||||
const image = () => {
|
||||
const url = split.override || split.cluster?.meta.icon_url || split.cluster?.meta.icon;
|
||||
|
||||
if (url === undefined || url === null)
|
||||
return defaultCover;
|
||||
return split.fallback || defaultCover;
|
||||
|
||||
if (url.startsWith('/'))
|
||||
return convertFileSrc(url);
|
||||
|
|
@ -23,17 +30,33 @@ function ClusterCover(props: ClusterCoverProps) {
|
|||
return url;
|
||||
};
|
||||
|
||||
const Wrapper = (props: ParentProps) => (
|
||||
<Show when={split.linearBlur !== undefined} fallback={<>{props.children}</>}>
|
||||
<div
|
||||
class={`${styles.linearBlur} ${split.linearBlur?.class || ''}`}
|
||||
style={{
|
||||
'--degrees': `${split.linearBlur?.degrees ?? 0}deg`,
|
||||
'--blur': `${split.linearBlur?.blur ?? 0}px`,
|
||||
}}
|
||||
children={props.children}
|
||||
/>
|
||||
</Show>
|
||||
);
|
||||
|
||||
return (
|
||||
<img
|
||||
{...rest}
|
||||
src={image()}
|
||||
onError={(e) => {
|
||||
e.currentTarget.src = split.override ? convertFileSrc(split.override) : defaultCover;
|
||||
if (split.onError)
|
||||
<Wrapper>
|
||||
<img
|
||||
{...rest}
|
||||
class={`${styles.cover} ${split.class || ''}`}
|
||||
src={image()}
|
||||
onError={(e) => {
|
||||
e.currentTarget.src = split.override ? convertFileSrc(split.override) : defaultCover;
|
||||
if (split.onError)
|
||||
// @ts-expect-error -- jsx stuff
|
||||
split.onError!(e);
|
||||
}}
|
||||
/>
|
||||
split.onError!(e);
|
||||
}}
|
||||
/>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { type JSX, Match, Show, Switch, createEffect, createSignal } from 'solid-js';
|
||||
import { InfoCircleIcon } from '@untitled-theme/icons-solid';
|
||||
import TimeAgo from '../../TimeAgo';
|
||||
import { TimeAgo } from '../../DynamicTime';
|
||||
import { PausableTimer } from '~utils';
|
||||
|
||||
type NotificationComponentProps = Core.Notification & {
|
||||
|
|
|
|||
|
|
@ -1,18 +1,14 @@
|
|||
import {
|
||||
ChevronRightIcon,
|
||||
DotsVerticalIcon,
|
||||
EyeIcon,
|
||||
PlayIcon,
|
||||
PlusIcon,
|
||||
SearchMdIcon,
|
||||
} from '@untitled-theme/icons-solid';
|
||||
import type { Accessor, Setter } from 'solid-js';
|
||||
import { For, Show, createEffect, createSignal } from 'solid-js';
|
||||
import { useNavigate } from '@solidjs/router';
|
||||
|
||||
import { mergeRefs } from '@solid-primitives/refs';
|
||||
import image from '../../assets/images/header.png';
|
||||
import ContextMenu from '../components/overlay/ContextMenu';
|
||||
import BannerBackground from '../../assets/images/header.png';
|
||||
import Button from '../components/base/Button';
|
||||
import TextField from '../components/base/TextField';
|
||||
import ClusterRoot from './cluster/ClusterRoot';
|
||||
|
|
@ -221,8 +217,18 @@ function Banner(props: BannerProps) {
|
|||
});
|
||||
|
||||
return (
|
||||
<div class="relative w-full h-52">
|
||||
<img src={image} class="absolute rounded-xl w-full h-52 object-cover" />
|
||||
<div class="relative w-full h-52 min-h-52 overflow-hidden rounded-xl">
|
||||
<ClusterCover
|
||||
class="absolute rounded-xl w-full h-52 object-cover"
|
||||
linearBlur={{
|
||||
degrees: 270,
|
||||
blur: 30,
|
||||
class: 'after:right-1/3!',
|
||||
}}
|
||||
cluster={cluster()}
|
||||
fallback={BannerBackground}
|
||||
/>
|
||||
|
||||
<div class="relative z-10 h-full px-8 py-6 text-fg-primary flex flex-col justify-between items-start">
|
||||
<div class="flex flex-col gap-y-2">
|
||||
<h1>{cluster()?.meta.name || 'Create a cluster'}</h1>
|
||||
|
|
@ -258,7 +264,7 @@ function Banner(props: BannerProps) {
|
|||
<Button
|
||||
buttonStyle="iconSecondary"
|
||||
class="bg-op-10!"
|
||||
children={<ChevronRightIcon />}
|
||||
children={<DotsVerticalIcon />}
|
||||
onClick={() => ClusterRoot.open(navigate, cluster()!.uuid)}
|
||||
/>
|
||||
</>
|
||||
|
|
@ -276,43 +282,8 @@ function Banner(props: BannerProps) {
|
|||
);
|
||||
}
|
||||
|
||||
interface ClusterCardContextMenuProps {
|
||||
pos: Accessor<{ x: number; y: number }>;
|
||||
contextMenuVisible: Accessor<boolean>;
|
||||
setContextMenuVisible: Setter<boolean>;
|
||||
id: string;
|
||||
}
|
||||
|
||||
function ClusterCardContextMenu(props: ClusterCardContextMenuProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
pos={props.pos}
|
||||
visible={props.contextMenuVisible}
|
||||
setVisible={props.setContextMenuVisible}
|
||||
>
|
||||
<ContextMenu.Row
|
||||
icon={<EyeIcon />}
|
||||
text="View"
|
||||
onClick={() => ClusterRoot.open(navigate, props.id)}
|
||||
/>
|
||||
|
||||
<ContextMenu.Seperator />
|
||||
|
||||
<ContextMenu.Row
|
||||
icon={<PlayIcon />}
|
||||
text="Launch"
|
||||
onClick={() => {}}
|
||||
/>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function ClusterCard(props: Cluster) {
|
||||
const navigate = useNavigate();
|
||||
const [pos, setPos] = createSignal({ x: 0, y: 0 });
|
||||
const [contextMenuVisible, setContextMenuVisible] = createSignal(false);
|
||||
// const sortable = createSortable(props.uuid, props);
|
||||
let ref!: HTMLDivElement;
|
||||
|
||||
|
|
@ -320,22 +291,10 @@ function ClusterCard(props: Cluster) {
|
|||
navigate(`/clusters/?id=${props.uuid}`);
|
||||
}
|
||||
|
||||
function openContextMenu(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setContextMenuVisible(!contextMenuVisible());
|
||||
}
|
||||
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
setPos({ x: e.clientX, y: e.clientY });
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
onClick={e => openClusterPage(e)}
|
||||
onMouseDown={e => onMouseDown(e)}
|
||||
onContextMenu={e => openContextMenu(e)}
|
||||
ref={mergeRefs(
|
||||
ref,
|
||||
// sortable
|
||||
|
|
@ -353,9 +312,9 @@ function ClusterCard(props: Cluster) {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="z-10 p-3 flex flex-row items-center justify-between">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<p class="h-4 font-medium">{props.meta.name}</p>
|
||||
<div class="z-10 p-3 flex flex-row gap-x-3 items-center justify-between">
|
||||
<div class="flex flex-col gap-1.5 overflow-hidden h-8">
|
||||
<p class="h-4 font-medium text-ellipsis whitespace-nowrap">{props.meta.name}</p>
|
||||
<p class="h-4 text-xs">
|
||||
{upperFirst(props.meta.loader)}
|
||||
{' '}
|
||||
|
|
@ -364,18 +323,18 @@ function ClusterCard(props: Cluster) {
|
|||
{props.instance.mods && `• ${props.mods} mods`} */}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={e => openContextMenu(e)} buttonStyle="icon" class="w-8 h-8">
|
||||
<DotsVerticalIcon />
|
||||
<Button
|
||||
buttonStyle="iconSecondary"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
ClusterRoot.launch(navigate, props.uuid);
|
||||
}}
|
||||
>
|
||||
<PlayIcon class="w-4! h-4!" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ClusterCardContextMenu
|
||||
contextMenuVisible={contextMenuVisible}
|
||||
setContextMenuVisible={setContextMenuVisible}
|
||||
pos={pos}
|
||||
id={props.uuid}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,88 @@
|
|||
import ScrollableContainer from '~ui/components/ScrollableContainer';
|
||||
import { type Params, useNavigate, useSearchParams } from '@solidjs/router';
|
||||
import { createEffect, createSignal, onCleanup, onMount } from 'solid-js';
|
||||
import type { UnlistenFn } from '@tauri-apps/api/event';
|
||||
import ClusterRoot from './ClusterRoot';
|
||||
import { bridge } from '~imports';
|
||||
import Sidebar from '~ui/components/Sidebar';
|
||||
import useClusterContext from '~ui/hooks/useCluster';
|
||||
import { TimeAgo } from '~ui/components/DynamicTime';
|
||||
import Tooltip from '~ui/components/base/Tooltip';
|
||||
import useCommand from '~ui/hooks/useCommand';
|
||||
import FormattedLog from '~ui/components/content/FormattedLog';
|
||||
|
||||
interface ClusterGameParams extends Params {
|
||||
process_uuid: string;
|
||||
};
|
||||
|
||||
function ClusterGame() {
|
||||
const _cluster = useClusterContext();
|
||||
const [cluster] = useClusterContext();
|
||||
const [params] = useSearchParams<ClusterGameParams>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// TODO: Save cluster settings
|
||||
const [unlisten, setUnlisten] = createSignal<UnlistenFn>();
|
||||
const [startedAt, setStartedAt] = createSignal<number>();
|
||||
const [pid] = useCommand(bridge.commands.getPidByUuid, params.process_uuid || '');
|
||||
|
||||
createEffect(() => {
|
||||
if (params.process_uuid === undefined)
|
||||
throw new Error('process_uuid is required');
|
||||
|
||||
const c = cluster();
|
||||
if (c === undefined)
|
||||
throw new Error('Cluster not found');
|
||||
|
||||
if (typeof c.meta.played_at !== 'string')
|
||||
setStartedAt(Date.now());
|
||||
else
|
||||
setStartedAt(new Date(c.meta.played_at).getTime());
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
const unlisten = await bridge.events.processPayload.listen(({ payload }) => {
|
||||
if (payload.event === 'finished' && payload.uuid === params.process_uuid && cluster() !== undefined)
|
||||
ClusterRoot.open(navigate, cluster()!.uuid);
|
||||
});
|
||||
|
||||
setUnlisten(() => unlisten);
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
unlisten()?.();
|
||||
});
|
||||
|
||||
const startedAtFormatted = (startedAt: number) => {
|
||||
const date = new Date(startedAt);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
return (
|
||||
<Sidebar.Page>
|
||||
<h1>Game Running</h1>
|
||||
<ScrollableContainer>
|
||||
<p>TODO</p>
|
||||
</ScrollableContainer>
|
||||
<div class="flex flex-col gap-y-2">
|
||||
<div class="flex flex-row">
|
||||
<Tooltip text={startedAtFormatted(startedAt() || 0)}>
|
||||
Started:
|
||||
{' '}
|
||||
<strong>
|
||||
<TimeAgo timestamp={startedAt() || 0} />
|
||||
</strong>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
PID:
|
||||
{' '}
|
||||
<strong>{pid() || 0}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FormattedLog log="aaa" />
|
||||
</Sidebar.Page>
|
||||
);
|
||||
}
|
||||
|
||||
ClusterGame.buildUrl = function (uuid: string, process_uuid: string): URLSearchParams {
|
||||
return new URLSearchParams({ id: uuid, process_uuid });
|
||||
};
|
||||
|
||||
export default ClusterGame;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { For, Show, createEffect, createSignal, untrack } from 'solid-js';
|
||||
import { OverlayScrollbarsComponent } from 'overlayscrollbars-solid';
|
||||
import { LinkExternal01Icon, Upload01Icon } from '@untitled-theme/icons-solid';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { join } from 'pathe';
|
||||
|
|
@ -108,11 +107,7 @@ function ClusterLogs() {
|
|||
</div>
|
||||
|
||||
<Show when={logContent() !== null} fallback={<span>No logs were found.</span>}>
|
||||
<div class="bg-component-bg flex flex-1 h-full font-mono font-medium overflow-auto p-2 rounded-md mt-2">
|
||||
<OverlayScrollbarsComponent class="flex-1 h-full relative">
|
||||
<FormattedLog log={logContent()!} />
|
||||
</OverlayScrollbarsComponent>
|
||||
</div>
|
||||
<FormattedLog log={logContent()!} />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,35 +1,184 @@
|
|||
import { Edit02Icon, ImagePlusIcon, PlayIcon, Save01Icon, Share07Icon, Trash01Icon } from '@untitled-theme/icons-solid';
|
||||
import { Show, createSignal, untrack } from 'solid-js';
|
||||
import { Edit02Icon, FolderIcon, ImagePlusIcon, LinkExternal01Icon, PlayIcon, Save01Icon, Share07Icon, Trash01Icon } from '@untitled-theme/icons-solid';
|
||||
import { type Accessor, type Setter, Show, createSignal, untrack } from 'solid-js';
|
||||
import { useBeforeLeave, useNavigate } from '@solidjs/router';
|
||||
import * as dialog from '@tauri-apps/plugin-dialog';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import ClusterCover from '../../components/game/ClusterCover';
|
||||
import LoaderIcon from '../../components/game/LoaderIcon';
|
||||
import Button from '../../components/base/Button';
|
||||
import ClusterRoot from './ClusterRoot';
|
||||
import ScrollableContainer from '~ui/components/ScrollableContainer';
|
||||
import Sidebar from '~ui/components/Sidebar';
|
||||
import type { Cluster } from '~bindings';
|
||||
import { upperFirst } from '~utils';
|
||||
import { secondsToWords, upperFirst } from '~utils';
|
||||
import useClusterContext from '~ui/hooks/useCluster';
|
||||
import Modal from '~ui/components/overlay/Modal';
|
||||
import { bridge } from '~imports';
|
||||
import { tryResult } from '~ui/hooks/useCommand';
|
||||
import TextField from '~ui/components/base/TextField';
|
||||
import SettingsRow from '~ui/components/SettingsRow';
|
||||
import useSettingsContext from '~ui/hooks/useSettings';
|
||||
import joinPath from '~utils/helpers';
|
||||
|
||||
function ClusterOverview() {
|
||||
const settings = useSettingsContext();
|
||||
const [cluster, { refetch }] = useClusterContext();
|
||||
const [deleteVisible, setDeleteVisible] = createSignal(false);
|
||||
const [saveVisible, setSaveVisible] = createSignal(false);
|
||||
|
||||
const [editMode, setEditMode] = createSignal(false);
|
||||
const [newName, setNewName] = createSignal('');
|
||||
const [newCover, setNewCover] = createSignal('');
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
useBeforeLeave((e) => {
|
||||
if (editMode() && madeChanges()) {
|
||||
e.preventDefault();
|
||||
setSaveVisible(true);
|
||||
}
|
||||
});
|
||||
|
||||
function getPath() {
|
||||
const clusterPath = cluster()?.path;
|
||||
const configDir = settings.config_dir;
|
||||
|
||||
if (typeof clusterPath !== 'string' || typeof configDir !== 'string')
|
||||
return '';
|
||||
|
||||
return joinPath(configDir, 'clusters', clusterPath);
|
||||
}
|
||||
|
||||
function openPath() {
|
||||
open(getPath());
|
||||
}
|
||||
|
||||
async function deleteCluster() {
|
||||
await bridge.commands.removeCluster(cluster()!.uuid);
|
||||
navigate('/');
|
||||
}
|
||||
|
||||
function madeChanges() {
|
||||
const name = untrack(() => newName());
|
||||
const cover = untrack(() => newCover());
|
||||
const c = untrack(() => cluster());
|
||||
|
||||
if (!c)
|
||||
return false;
|
||||
|
||||
const changedName = (name.length > 0 && name !== c.meta.name);
|
||||
const changedCover = (cover.length > 0 && (cover !== c.meta.icon_url || cover !== c.meta.icon));
|
||||
|
||||
return changedName || changedCover;
|
||||
}
|
||||
|
||||
function toggleEditMode() {
|
||||
const next = !untrack(() => editMode());
|
||||
|
||||
if (next === false && madeChanges()) {
|
||||
setSaveVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setEditMode(next);
|
||||
}
|
||||
|
||||
async function pushEdits() {
|
||||
const c = untrack(() => cluster());
|
||||
|
||||
if (!c)
|
||||
return;
|
||||
|
||||
await bridge.commands.editCluster(
|
||||
c.uuid,
|
||||
newName().length > 0 ? newName() : null,
|
||||
newCover().length > 0 ? newCover() : c.meta.icon_url || c.meta.icon || null,
|
||||
);
|
||||
|
||||
refetch();
|
||||
}
|
||||
|
||||
function dontSave() {
|
||||
setSaveVisible(false);
|
||||
setEditMode(false);
|
||||
}
|
||||
|
||||
function save() {
|
||||
pushEdits();
|
||||
setSaveVisible(false);
|
||||
setEditMode(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Sidebar.Page>
|
||||
<h1>Overview</h1>
|
||||
<ScrollableContainer>
|
||||
<Banner cluster={cluster()!} refetch={refetch} showDeletePrompt={() => setDeleteVisible(true)} />
|
||||
<Banner
|
||||
cluster={cluster()!}
|
||||
refetch={refetch}
|
||||
|
||||
saveVisible={saveVisible}
|
||||
setSaveVisible={setSaveVisible}
|
||||
|
||||
editMode={editMode}
|
||||
newName={newName}
|
||||
setNewName={setNewName}
|
||||
newCover={newCover}
|
||||
setNewCover={setNewCover}
|
||||
/>
|
||||
|
||||
<SettingsRow.Header>Folders and Files</SettingsRow.Header>
|
||||
<SettingsRow
|
||||
title="Cluster Folder"
|
||||
description={getPath()}
|
||||
icon={<FolderIcon />}
|
||||
disabled={editMode()}
|
||||
children={(
|
||||
<Button
|
||||
buttonStyle="primary"
|
||||
children="Open"
|
||||
iconLeft={<LinkExternal01Icon />}
|
||||
onClick={openPath}
|
||||
disabled={editMode()}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<SettingsRow.Header>Cluster Actions</SettingsRow.Header>
|
||||
<SettingsRow
|
||||
title="Edit Cluster"
|
||||
description="Edit the cluster name and cover image."
|
||||
icon={<Edit02Icon />}
|
||||
children={(
|
||||
<Button.Toggle
|
||||
buttonStyle={editMode() === false ? 'secondary' : 'primary'}
|
||||
iconLeft={(
|
||||
<Show
|
||||
when={editMode() === false}
|
||||
fallback={<Save01Icon />}
|
||||
children={<Edit02Icon />}
|
||||
/>
|
||||
)}
|
||||
children={editMode() ? 'Save' : 'Edit'}
|
||||
checked={editMode}
|
||||
onChecked={toggleEditMode}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SettingsRow
|
||||
title="Delete Cluster"
|
||||
description="Delete this cluster and all its data."
|
||||
icon={<Trash01Icon />}
|
||||
disabled={editMode()}
|
||||
children={(
|
||||
<Button
|
||||
buttonStyle="danger"
|
||||
children="Delete"
|
||||
iconLeft={<Trash01Icon />}
|
||||
onClick={() => setDeleteVisible(true)}
|
||||
disabled={editMode()}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</ScrollableContainer>
|
||||
|
||||
<Modal.Delete
|
||||
|
|
@ -37,34 +186,53 @@ function ClusterOverview() {
|
|||
setVisible={setDeleteVisible}
|
||||
onDelete={deleteCluster}
|
||||
/>
|
||||
|
||||
<Modal.Simple
|
||||
title="Save Changes?"
|
||||
visible={saveVisible}
|
||||
setVisible={setSaveVisible}
|
||||
children="Do you want to save your changes?"
|
||||
buttons={[
|
||||
<Button
|
||||
buttonStyle="secondary"
|
||||
children="Cancel"
|
||||
onClick={() => setSaveVisible(false)}
|
||||
/>,
|
||||
<Button
|
||||
buttonStyle="danger"
|
||||
children="No"
|
||||
onClick={dontSave}
|
||||
/>,
|
||||
<Button
|
||||
buttonStyle="primary"
|
||||
children="Yes"
|
||||
onClick={save}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
</Sidebar.Page>
|
||||
);
|
||||
}
|
||||
|
||||
interface BannerProps {
|
||||
cluster: Cluster;
|
||||
saveVisible: Accessor<boolean>;
|
||||
setSaveVisible: Setter<boolean>;
|
||||
editMode: Accessor<boolean>;
|
||||
newName: Accessor<string>;
|
||||
setNewName: Setter<string>;
|
||||
newCover: Accessor<string>;
|
||||
setNewCover: Setter<string>;
|
||||
refetch: () => void;
|
||||
showDeletePrompt: () => void;
|
||||
}
|
||||
|
||||
function Banner(props: BannerProps) {
|
||||
const [editMode, setEditMode] = createSignal(false);
|
||||
const [newName, setNewName] = createSignal('');
|
||||
const [newCover, setNewCover] = createSignal('');
|
||||
|
||||
const [modalVisible, setModalVisible] = createSignal(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
async function launch() {
|
||||
const [_uuid, _pid] = await tryResult(bridge.commands.runCluster, props.cluster.uuid);
|
||||
ClusterRoot.launch(navigate, props.cluster.uuid);
|
||||
}
|
||||
|
||||
useBeforeLeave((e) => {
|
||||
if (editMode() && madeChanges()) {
|
||||
e.preventDefault();
|
||||
setModalVisible(true);
|
||||
}
|
||||
});
|
||||
|
||||
async function launchFilePicker() {
|
||||
const selected = await dialog.open({
|
||||
multiple: false,
|
||||
|
|
@ -78,62 +246,20 @@ function Banner(props: BannerProps) {
|
|||
if (selected === null)
|
||||
return;
|
||||
|
||||
setNewCover(selected.path);
|
||||
props.setNewCover(selected.path);
|
||||
}
|
||||
|
||||
function updateName(name: string) {
|
||||
if (name.length > 30 || name.length <= 0)
|
||||
return;
|
||||
|
||||
setNewName(name);
|
||||
}
|
||||
|
||||
async function pushEdits() {
|
||||
await bridge.commands.editCluster(
|
||||
props.cluster.uuid,
|
||||
newName().length > 0 ? newName() : null,
|
||||
newCover().length > 0 ? newCover() : props.cluster.meta.icon_url || props.cluster.meta.icon || null,
|
||||
);
|
||||
|
||||
props.refetch();
|
||||
}
|
||||
|
||||
function madeChanges() {
|
||||
const name = untrack(() => newName());
|
||||
const cover = untrack(() => newCover());
|
||||
|
||||
const changedName = (name.length > 0 && name !== props.cluster.meta.name);
|
||||
const changedCover = (cover.length > 0 && (cover !== props.cluster.meta.icon_url || cover !== props.cluster.meta.icon));
|
||||
|
||||
return changedName || changedCover;
|
||||
}
|
||||
|
||||
async function toggleEditMode() {
|
||||
const next = !untrack(() => editMode());
|
||||
|
||||
if (next === false && madeChanges()) {
|
||||
setModalVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setEditMode(next);
|
||||
}
|
||||
|
||||
function dontSave() {
|
||||
setModalVisible(false);
|
||||
setEditMode(false);
|
||||
}
|
||||
|
||||
function save() {
|
||||
pushEdits();
|
||||
setModalVisible(false);
|
||||
setEditMode(false);
|
||||
props.setNewName(name);
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex flex-row bg-component-bg rounded-xl p-2.5 gap-x-2.5 h-37">
|
||||
<div class="rounded-lg overflow-hidden border border-gray-10 relative h-full w-auto aspect-ratio-video">
|
||||
<Show when={editMode()}>
|
||||
<div class="rounded-lg overflow-hidden border border-gray-10 relative h-full w-57 min-w-57 aspect-ratio-video">
|
||||
<Show when={props.editMode()}>
|
||||
<div
|
||||
onClick={launchFilePicker}
|
||||
class="bg-black/50 opacity-50 hover:opacity-100 w-full h-full absolute flex justify-center items-center"
|
||||
|
|
@ -142,15 +268,15 @@ function Banner(props: BannerProps) {
|
|||
</div>
|
||||
</Show>
|
||||
|
||||
<ClusterCover override={newCover()} cluster={props.cluster} class="h-full w-full object-cover" />
|
||||
<ClusterCover override={props.newCover()} cluster={props.cluster} class="h-full w-full object-cover" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col flex-1 gap-y-.5 justify-between text-fg-primary">
|
||||
<div class="flex flex-col w-full overflow-hidden gap-y-.5 justify-between text-fg-primary">
|
||||
<div>
|
||||
<Show
|
||||
when={editMode()}
|
||||
when={props.editMode()}
|
||||
fallback={
|
||||
<h2 class="text-2xl">{props.cluster.meta.name}</h2>
|
||||
<h2 class="text-2xl break-words text-wrap">{props.cluster.meta.name}</h2>
|
||||
}
|
||||
>
|
||||
<TextField
|
||||
|
|
@ -160,88 +286,60 @@ function Banner(props: BannerProps) {
|
|||
onChange={e => updateName(e.target.value)}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
</div>
|
||||
|
||||
<div class={`flex flex-1 flex-col justify-between items-start ${editMode() ? 'opacity-20' : ''}`}>
|
||||
<span class="flex flex-row items-center gap-x-1">
|
||||
<LoaderIcon loader={props.cluster.meta.loader} class="w-5" />
|
||||
<span>{upperFirst(props.cluster.meta.loader || 'unknown')}</span>
|
||||
{props.cluster.meta.loader_version && <span>{props.cluster.meta.loader_version.id}</span>}
|
||||
<span>{props.cluster.meta.mc_version}</span>
|
||||
</span>
|
||||
<span class="text-xs text-fg-secondary">
|
||||
Played for
|
||||
{' '}
|
||||
<b>{((props.cluster.meta.overall_played || 0 as unknown as bigint)).toString()}</b>
|
||||
{' '}
|
||||
hours
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-row">
|
||||
<div
|
||||
class="flex flex-1 flex-col justify-between items-start"
|
||||
classList={{
|
||||
'text-fg-primary-disabled': props.editMode(),
|
||||
}}
|
||||
>
|
||||
<span class="flex flex-row items-center gap-x-1">
|
||||
<LoaderIcon
|
||||
loader={props.cluster.meta.loader}
|
||||
class="w-5"
|
||||
classList={{
|
||||
'opacity-50': props.editMode(),
|
||||
}}
|
||||
/>
|
||||
<span>{upperFirst(props.cluster.meta.loader || 'unknown')}</span>
|
||||
{props.cluster.meta.loader_version && <span>{props.cluster.meta.loader_version.id}</span>}
|
||||
<span>{props.cluster.meta.mc_version}</span>
|
||||
</span>
|
||||
<span
|
||||
class="text-xs text-fg-secondary"
|
||||
classList={{
|
||||
'text-fg-secondary-disabled': props.editMode(),
|
||||
}}
|
||||
>
|
||||
Played for
|
||||
{' '}
|
||||
<b>{secondsToWords((props.cluster.meta.overall_played || 0n))}</b>
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="flex flex-row items-end gap-x-2.5 *:h-8">
|
||||
|
||||
<div class="flex flex-row items-end gap-x-2.5 *:h-8">
|
||||
|
||||
<Button
|
||||
buttonStyle="iconSecondary"
|
||||
children={<Share07Icon />}
|
||||
disabled={editMode()}
|
||||
/>
|
||||
|
||||
<Button
|
||||
buttonStyle="iconDanger"
|
||||
children={<Trash01Icon />}
|
||||
onClick={props.showDeletePrompt}
|
||||
disabled={editMode()}
|
||||
/>
|
||||
|
||||
<Button
|
||||
buttonStyle="primary"
|
||||
iconLeft={<PlayIcon />}
|
||||
children="Launch"
|
||||
class="!w-auto"
|
||||
onClick={launch}
|
||||
disabled={editMode()}
|
||||
/>
|
||||
|
||||
<Button.Toggle
|
||||
buttonStyle="iconSecondary"
|
||||
children={(
|
||||
<Show
|
||||
when={editMode() === false}
|
||||
fallback={<Save01Icon />}
|
||||
children={<Edit02Icon />}
|
||||
<Button
|
||||
buttonStyle="iconSecondary"
|
||||
children={<Share07Icon />}
|
||||
disabled={props.editMode()}
|
||||
/>
|
||||
)}
|
||||
checked={editMode}
|
||||
onChecked={toggleEditMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Modal.Simple
|
||||
title="Save Changes?"
|
||||
visible={modalVisible}
|
||||
setVisible={setModalVisible}
|
||||
children="Do you want to save your changes?"
|
||||
buttons={[
|
||||
<Button
|
||||
buttonStyle="secondary"
|
||||
children="Cancel"
|
||||
onClick={() => setModalVisible(false)}
|
||||
/>,
|
||||
<Button
|
||||
buttonStyle="danger"
|
||||
children="No"
|
||||
onClick={dontSave}
|
||||
/>,
|
||||
<Button
|
||||
buttonStyle="primary"
|
||||
children="Yes"
|
||||
onClick={save}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
buttonStyle="primary"
|
||||
iconLeft={<PlayIcon />}
|
||||
children="Launch"
|
||||
class="!w-auto"
|
||||
onClick={launch}
|
||||
disabled={props.editMode()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { type Navigator, Route, useSearchParams } from '@solidjs/router';
|
||||
import type { ParentProps } from 'solid-js';
|
||||
import { type Navigator, Route, useIsRouting, useSearchParams } from '@solidjs/router';
|
||||
import { type ParentProps, createEffect, createResource, createSignal, onCleanup, onMount } from 'solid-js';
|
||||
import { EyeIcon, File06Icon, Globe04Icon, Image03Icon, PackagePlusIcon, Settings04Icon } from '@untitled-theme/icons-solid';
|
||||
import type { UnlistenFn } from '@tauri-apps/api/event';
|
||||
import Sidebar from '../../components/Sidebar';
|
||||
import AnimatedRoutes from '../../components/AnimatedRoutes';
|
||||
import ErrorBoundary from '../../components/ErrorBoundary';
|
||||
|
|
@ -11,6 +12,10 @@ import ClusterScreenshots from './ClusterScreenshots';
|
|||
import ClusterSettings from './ClusterSettings';
|
||||
import ClusterGame from './ClusterGame';
|
||||
import useClusterContext, { ClusterProvider } from '~ui/hooks/useCluster';
|
||||
import { supportsMods } from '~utils/helpers';
|
||||
import { tryResult } from '~ui/hooks/useCommand';
|
||||
import { bridge } from '~imports';
|
||||
import PlayerHead from '~ui/components/game/PlayerHead';
|
||||
|
||||
function ClusterRoutes() {
|
||||
return (
|
||||
|
|
@ -52,12 +57,40 @@ ClusterRoot.open = function (navigate: Navigator, uuid: string) {
|
|||
};
|
||||
|
||||
ClusterRoot.launch = function (navigate: Navigator, uuid: string) {
|
||||
// TODO: Launch game page
|
||||
navigate(`/clusters/game?id=${uuid}&launch=true`);
|
||||
tryResult(bridge.commands.runCluster, uuid).then(([process_uuid]) => {
|
||||
navigate(`/clusters/game?${ClusterGame.buildUrl(uuid, process_uuid).toString()}`);
|
||||
});
|
||||
};
|
||||
|
||||
function ClusterSidebar() {
|
||||
const [cluster] = useClusterContext();
|
||||
const [cluster, { refetch: refetchCluster }] = useClusterContext();
|
||||
const [listener, setListener] = createSignal<UnlistenFn>();
|
||||
const [runningProcesses, { refetch: refetchProcesses }] = createResource([], async () => {
|
||||
const c = cluster();
|
||||
if (!c || typeof c.path !== 'string')
|
||||
return [];
|
||||
|
||||
return tryResult(bridge.commands.getProcessesByPath, c.path);
|
||||
});
|
||||
|
||||
const isRouting = useIsRouting();
|
||||
|
||||
createEffect(() => {
|
||||
if (isRouting())
|
||||
refetchCluster();
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
const unlisten = await bridge.events.processPayload.listen(() => {
|
||||
refetchProcesses();
|
||||
});
|
||||
|
||||
setListener(() => unlisten);
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
listener?.()?.();
|
||||
});
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
|
|
@ -65,14 +98,22 @@ function ClusterSidebar() {
|
|||
links={{
|
||||
Cluster: [
|
||||
[<EyeIcon />, 'Overview', '/'],
|
||||
// TODO: Better way of checking mods
|
||||
(cluster()?.meta.loader !== 'vanilla' ? [<PackagePlusIcon />, 'Mods', '/mods'] : undefined),
|
||||
(supportsMods(cluster?.()) ? [<PackagePlusIcon />, 'Mods', '/mods'] : undefined),
|
||||
[<Image03Icon />, 'Screenshots', '/screenshots'],
|
||||
[<Globe04Icon />, 'Worlds', '/worlds'],
|
||||
[<File06Icon />, 'Logs', '/logs'],
|
||||
[<Settings04Icon />, 'Game Settings', '/settings'],
|
||||
// TODO: Add game page CONDITIONALLY
|
||||
],
|
||||
// TODO: Support multiple running clusters and add used account's avatar as the icon
|
||||
...(runningProcesses() && runningProcesses()!.length > 0
|
||||
? {
|
||||
Running: runningProcesses()!.map((process_uuid, index) => {
|
||||
const icon = <PlayerHead uuid={null} />; // : <Settings01Icon />;
|
||||
|
||||
return [icon, `Process #${index + 1}`, '/game', ClusterGame.buildUrl(cluster()!.uuid, process_uuid)];
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import useSettingsContext from '~ui/hooks/useSettings';
|
|||
import SettingsMinecraft from '~ui/pages/settings/game/SettingsMinecraft';
|
||||
|
||||
function ClusterSettings() {
|
||||
const cluster = useClusterContext();
|
||||
const [cluster] = useClusterContext();
|
||||
const settings = useSettingsContext();
|
||||
|
||||
// TODO: Save cluster settings
|
||||
|
|
@ -16,14 +16,14 @@ function ClusterSettings() {
|
|||
<ScrollableContainer>
|
||||
<SettingsMinecraft.Settings
|
||||
fullscreen={{
|
||||
get: cluster.force_fullscreen ?? settings.force_fullscreen ?? false,
|
||||
set: value => cluster.force_fullscreen = value,
|
||||
isGlobal: cluster.force_fullscreen === null,
|
||||
get: cluster()?.force_fullscreen ?? settings.force_fullscreen ?? false,
|
||||
set: value => cluster() && (cluster()!.force_fullscreen = value),
|
||||
isGlobal: cluster()?.force_fullscreen === null,
|
||||
}}
|
||||
resolution={{
|
||||
get: cluster.resolution ?? settings.resolution,
|
||||
set: value => cluster.resolution = value,
|
||||
isGlobal: cluster.resolution === undefined || cluster.resolution === null,
|
||||
get: cluster()?.resolution ?? settings.resolution,
|
||||
set: value => cluster() && (cluster()!.resolution = value),
|
||||
isGlobal: cluster()?.resolution === undefined || cluster()?.resolution === null,
|
||||
}}
|
||||
/>
|
||||
</ScrollableContainer>
|
||||
|
|
|
|||
|
|
@ -40,7 +40,6 @@ function SettingsRoot(props: ParentProps) {
|
|||
<div class="mt-8 flex flex-col justify-between">
|
||||
<Sidebar
|
||||
base="/settings"
|
||||
state={{}}
|
||||
links={{
|
||||
'Launcher Settings': [
|
||||
[<Rocket02Icon />, 'General', '/'],
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { ColorsIcon, PaintPourIcon, Speedometer04Icon } from '@untitled-theme/icons-solid';
|
||||
import { useBeforeLeave } from '@solidjs/router';
|
||||
import { createSignal } from 'solid-js';
|
||||
import SettingsRow from '../../../components/SettingsRow';
|
||||
import ScrollableContainer from '~ui/components/ScrollableContainer';
|
||||
import Button from '~ui/components/base/Button';
|
||||
|
|
@ -8,6 +10,15 @@ import useSettingsContext from '~ui/hooks/useSettings';
|
|||
|
||||
function SettingsAppearance() {
|
||||
const settings = useSettingsContext();
|
||||
const [shouldReload, setShouldReload] = createSignal(false);
|
||||
|
||||
useBeforeLeave((e) => {
|
||||
if (shouldReload()) {
|
||||
e.preventDefault();
|
||||
setShouldReload(false);
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Sidebar.Page>
|
||||
|
|
@ -32,7 +43,10 @@ function SettingsAppearance() {
|
|||
>
|
||||
<Toggle
|
||||
defaultChecked={settings.disable_animations ?? false}
|
||||
onChecked={value => settings.disable_animations = value}
|
||||
onChecked={(value) => {
|
||||
settings.disable_animations = value;
|
||||
setShouldReload(true);
|
||||
}}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</ScrollableContainer>
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ function SettingsGeneral() {
|
|||
<SettingsRow.Header>Folders and Files</SettingsRow.Header>
|
||||
|
||||
<SettingsRow
|
||||
title="Launcher Directory"
|
||||
title="Launcher Folder"
|
||||
description={settings.config_dir || 'UNKNOWN'}
|
||||
icon={<FolderIcon />}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -33,23 +33,40 @@ export function abbreviateNumber(n: number) {
|
|||
return `${n}`;
|
||||
};
|
||||
|
||||
export function secondsToWords(seconds: number | bigint): string {
|
||||
// pro lynith move
|
||||
// TODO: Probably should use https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/PluralRules
|
||||
export function pluralize(n: number, word: string): string {
|
||||
return n === 1 ? word : `${word}s`;
|
||||
}
|
||||
|
||||
export function secondsToWords(seconds: number | bigint, plural: boolean = true): string {
|
||||
const n = Number(seconds);
|
||||
|
||||
if (n < 60)
|
||||
return `${n} seconds`;
|
||||
else if (n < (60 * 60))
|
||||
return `${Math.floor(n / 60)} minutes`;
|
||||
else if (n < (60 * 60 * 24))
|
||||
return `${Math.floor(n / (60 * 60))} hours`;
|
||||
else if (n < (60 * 60 * 24 * 7))
|
||||
return `${Math.floor(n / (60 * 60 * 24))} days`;
|
||||
else if (n < (60 * 60 * 24 * 30))
|
||||
return `${Math.floor(n / (60 * 60 * 24 * 7))} weeks`;
|
||||
else if (n < (60 * 60 * 24 * 365))
|
||||
return `${Math.floor(n / (60 * 60 * 24 * 30))} months`;
|
||||
else if (n < (60 * 60 * 24 * 365 * 10))
|
||||
return `${Math.floor(n / (60 * 60 * 24 * 365))} years`;
|
||||
const func = (n: number) => {
|
||||
if (n < 60)
|
||||
return `${n} seconds`;
|
||||
else if (n < (60 * 60))
|
||||
return `${Math.floor(n / 60)} minutes`;
|
||||
else if (n < (60 * 60 * 24))
|
||||
return `${Math.floor(n / (60 * 60))} hours`;
|
||||
else if (n < (60 * 60 * 24 * 7))
|
||||
return `${Math.floor(n / (60 * 60 * 24))} days`;
|
||||
else if (n < (60 * 60 * 24 * 30))
|
||||
return `${Math.floor(n / (60 * 60 * 24 * 7))} weeks`;
|
||||
else if (n < (60 * 60 * 24 * 365))
|
||||
return `${Math.floor(n / (60 * 60 * 24 * 30))} months`;
|
||||
else if (n < (60 * 60 * 24 * 365 * 10))
|
||||
return `${Math.floor(n / (60 * 60 * 24 * 365))} years`;
|
||||
|
||||
return `${n} seconds`; // Hmmmmmm
|
||||
return `${n} seconds`; // Hmmmmmm
|
||||
};
|
||||
|
||||
const str = func(n);
|
||||
if (plural) {
|
||||
const n = str.split(' ')[0];
|
||||
const word = str.split(' ')[1];
|
||||
return `${n} ${pluralize(Number(n), word as string)}`;
|
||||
}
|
||||
|
||||
return str;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ pub async fn launch_and_authenticate() -> onelauncher::Result<()> {
|
|||
State::sync().await?;
|
||||
|
||||
println!("running minecraft");
|
||||
let c_lock = cluster::run(&cluster).await?;
|
||||
let c_lock = cluster::run_default(&cluster).await?;
|
||||
let uuid = c_lock.read().await.uuid;
|
||||
let pid = c_lock.read().await.current_child.read().await.id();
|
||||
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ pub async fn list_grouped(clear: Option<bool>) -> crate::Result<HashMap<String,
|
|||
|
||||
/// run a Minecraft [`Cluster`] using the default credentials.
|
||||
#[tracing::instrument]
|
||||
pub async fn run(path: &ClusterPath) -> crate::Result<Arc<RwLock<ProcessorChild>>> {
|
||||
pub async fn run_default(path: &ClusterPath) -> crate::Result<Arc<RwLock<ProcessorChild>>> {
|
||||
let state = State::get().await?;
|
||||
let default = {
|
||||
let mut users = state.users.write().await;
|
||||
|
|
@ -98,6 +98,21 @@ pub async fn run(path: &ClusterPath) -> crate::Result<Arc<RwLock<ProcessorChild>
|
|||
run_credentials(path, &default).await
|
||||
}
|
||||
|
||||
/// run a Minecraft [`Cluster`] using [`MinecraftCredentials`] for authentication.
|
||||
/// returns an [`Arc`] pointer to [`RwLock`] to [`ProcessorChild`].
|
||||
#[tracing::instrument(skip(creds))]
|
||||
#[onelauncher_macros::memory]
|
||||
pub async fn run(
|
||||
path: &ClusterPath,
|
||||
creds: Option<&MinecraftCredentials>
|
||||
) -> crate::Result<Arc<RwLock<ProcessorChild>>> {
|
||||
if let Some(creds) = creds {
|
||||
run_credentials(path, creds).await
|
||||
} else {
|
||||
run_default(path).await
|
||||
}
|
||||
}
|
||||
|
||||
/// run a Minecraft [`Cluster`] using [`MinecraftCredentials`] for authentication.
|
||||
/// returns an [`Arc`] pointer to [`RwLock`] to [`ProcessorChild`].
|
||||
#[tracing::instrument(skip(creds))]
|
||||
|
|
|
|||
|
|
@ -50,6 +50,13 @@ pub async fn get_running_clusters() -> crate::Result<Vec<Cluster>> {
|
|||
processor.running_clusters().await
|
||||
}
|
||||
|
||||
/// check if a cluster is running by its [`Uuid`].
|
||||
#[tracing::instrument]
|
||||
pub async fn is_cluster_running(uuid: Uuid) -> crate::Result<bool> {
|
||||
let clusters = get_running_clusters().await?;
|
||||
Ok(clusters.iter().any(|cluster| cluster.uuid == uuid))
|
||||
}
|
||||
|
||||
/// get all processes by a [`ClusterPath`].
|
||||
#[tracing::instrument]
|
||||
pub async fn get_uuids_by_cluster_path(cluster_path: ClusterPath) -> crate::Result<Vec<Uuid>> {
|
||||
|
|
@ -105,3 +112,19 @@ pub async fn wait_for(process: &mut ProcessorChild) -> crate::Result<()> {
|
|||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// get process pid by its [`Uuid`].
|
||||
#[tracing::instrument]
|
||||
pub async fn get_pid_by_uuid(uuid: Uuid) -> crate::Result<u32> {
|
||||
let state = State::get().await?;
|
||||
let processor = state.processor.read().await;
|
||||
Ok(processor.get(uuid)
|
||||
.ok_or(anyhow::anyhow!("process not found"))?
|
||||
.read()
|
||||
.await
|
||||
.current_child
|
||||
.read()
|
||||
.await
|
||||
.id()
|
||||
.ok_or(anyhow::anyhow!("process not found"))?)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue