diff --git a/apps/desktop/src/api/commands/cluster.rs b/apps/desktop/src/api/commands/cluster.rs index 23cf33e9..65d86853 100644 --- a/apps/desktop/src/api/commands/cluster.rs +++ b/apps/desktop/src/api/commands/cluster.rs @@ -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 diff --git a/apps/desktop/src/api/commands/mod.rs b/apps/desktop/src/api/commands/mod.rs index 8a49cff4..1b641a16 100644 --- a/apps/desktop/src/api/commands/mod.rs +++ b/apps/desktop/src/api/commands/mod.rs @@ -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, diff --git a/apps/desktop/src/api/commands/processor.rs b/apps/desktop/src/api/commands/processor.rs index 2c5df570..f7816308 100644 --- a/apps/desktop/src/api/commands/processor.rs +++ b/apps/desktop/src/api/commands/processor.rs @@ -8,12 +8,24 @@ pub async fn get_running_clusters() -> Result, String> { Ok(processor::get_running_clusters().await?) } +#[specta::specta] +#[tauri::command] +pub async fn is_cluster_running(uuid: Uuid) -> Result { + Ok(processor::is_cluster_running(uuid).await?) +} + #[specta::specta] #[tauri::command] pub async fn get_processes_by_path(path: ClusterPath) -> Result, String> { Ok(processor::get_uuids_by_cluster_path(path).await?) } +#[specta::specta] +#[tauri::command] +pub async fn get_pid_by_uuid(uuid: Uuid) -> Result { + Ok(processor::get_pid_by_uuid(uuid).await?) +} + #[specta::specta] #[tauri::command] pub async fn kill_process(uuid: Uuid) -> Result<(), String> { diff --git a/apps/frontend/src/globals.css b/apps/frontend/src/globals.css index fbc756a6..adb6ef8b 100644 --- a/apps/frontend/src/globals.css +++ b/apps/frontend/src/globals.css @@ -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) */ diff --git a/apps/frontend/src/ui/components/AnimatedRoutes.tsx b/apps/frontend/src/ui/components/AnimatedRoutes.tsx index 0b7142a1..e23d3ae9 100644 --- a/apps/frontend/src/ui/components/AnimatedRoutes.tsx +++ b/apps/frontend/src/ui/components/AnimatedRoutes.tsx @@ -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 ( - - {split.children} - + {split.children} + )} + children={( + + {split.children} + + )} + /> ); } diff --git a/apps/frontend/src/ui/components/DynamicTime.tsx b/apps/frontend/src/ui/components/DynamicTime.tsx new file mode 100644 index 00000000..a90e3607 --- /dev/null +++ b/apps/frontend/src/ui/components/DynamicTime.tsx @@ -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 ; +} + +type InternalTimeProps = TimeProps & { + format: (time: number) => string; +}; + +function InternalTime(props: InternalTimeProps) { + const [time, setTime] = createSignal(''); + const [intervalId, setIntervalId] = createSignal(undefined); + + onMount(() => { + const loop = () => { + setTime(props.format(props.timestamp)); + }; + + loop(); + setIntervalId(setInterval(loop, 1000)); + }); + + onCleanup(() => { + clearInterval(intervalId()!); + }); + + return {time()}; +} diff --git a/apps/frontend/src/ui/components/SettingsRow.tsx b/apps/frontend/src/ui/components/SettingsRow.tsx index 1062d4ff..cbc4f58c 100644 --- a/apps/frontend/src/ui/components/SettingsRow.tsx +++ b/apps/frontend/src/ui/components/SettingsRow.tsx @@ -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 (
{props.icon} @@ -22,7 +25,7 @@ function SettingsRow(props: SettingsRowProps) {

{props.description}

-
+
{props.children}
diff --git a/apps/frontend/src/ui/components/Sidebar.tsx b/apps/frontend/src/ui/components/Sidebar.tsx index e61b3956..c9d6f5b6 100644 --- a/apps/frontend/src/ui/components/Sidebar.tsx +++ b/apps/frontend/src/ui/components/Sidebar.tsx @@ -4,7 +4,7 @@ import { For, createEffect, splitProps } from 'solid-js'; type SidebarProps = ParentProps & { base: string; - links: Record; + links: Record; }; 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 ( 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' : ''}` } diff --git a/apps/frontend/src/ui/components/TimeAgo.tsx b/apps/frontend/src/ui/components/TimeAgo.tsx deleted file mode 100644 index 41cdcf72..00000000 --- a/apps/frontend/src/ui/components/TimeAgo.tsx +++ /dev/null @@ -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(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 {time()}; -} - -export default TimeAgo; diff --git a/apps/frontend/src/ui/components/base/Button.module.scss b/apps/frontend/src/ui/components/base/Button.module.scss index 7344d5da..7be37c94 100644 --- a/apps/frontend/src/ui/components/base/Button.module.scss +++ b/apps/frontend/src/ui/components/base/Button.module.scss @@ -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 > * { diff --git a/apps/frontend/src/ui/components/base/Toggle.tsx b/apps/frontend/src/ui/components/base/Toggle.tsx index 7d764e6f..09357bc5 100644 --- a/apps/frontend/src/ui/components/base/Toggle.tsx +++ b/apps/frontend/src/ui/components/base/Toggle.tsx @@ -18,7 +18,7 @@ function Toggle(props: ToggleProps) { return (
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} >
diff --git a/apps/frontend/src/ui/components/content/FormattedLog.tsx b/apps/frontend/src/ui/components/content/FormattedLog.tsx index ea251d23..bd84a7b3 100644 --- a/apps/frontend/src/ui/components/content/FormattedLog.tsx +++ b/apps/frontend/src/ui/components/content/FormattedLog.tsx @@ -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 ( - - - {line => } - - +
+ + + + {line => } + + + +
); } diff --git a/apps/frontend/src/ui/components/game/ClusterCover.module.scss b/apps/frontend/src/ui/components/game/ClusterCover.module.scss new file mode 100644 index 00000000..dbaf176e --- /dev/null +++ b/apps/frontend/src/ui/components/game/ClusterCover.module.scss @@ -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%); +} diff --git a/apps/frontend/src/ui/components/game/ClusterCover.tsx b/apps/frontend/src/ui/components/game/ClusterCover.tsx index 07a15f2a..6e6abe5c 100644 --- a/apps/frontend/src/ui/components/game/ClusterCover.tsx +++ b/apps/frontend/src/ui/components/game/ClusterCover.tsx @@ -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 & { 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) => ( + {props.children}}> +
+ + ); + return ( - { - e.currentTarget.src = split.override ? convertFileSrc(split.override) : defaultCover; - if (split.onError) + + { + e.currentTarget.src = split.override ? convertFileSrc(split.override) : defaultCover; + if (split.onError) // @ts-expect-error -- jsx stuff - split.onError!(e); - }} - /> + split.onError!(e); + }} + /> + ); } diff --git a/apps/frontend/src/ui/components/overlay/notifications/NotificationComponent.tsx b/apps/frontend/src/ui/components/overlay/notifications/NotificationComponent.tsx index 5f837e42..fdba4006 100644 --- a/apps/frontend/src/ui/components/overlay/notifications/NotificationComponent.tsx +++ b/apps/frontend/src/ui/components/overlay/notifications/NotificationComponent.tsx @@ -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 & { diff --git a/apps/frontend/src/ui/pages/Home.tsx b/apps/frontend/src/ui/pages/Home.tsx index 684cef6e..75917eac 100644 --- a/apps/frontend/src/ui/pages/Home.tsx +++ b/apps/frontend/src/ui/pages/Home.tsx @@ -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 ( -
- +
+ +

{cluster()?.meta.name || 'Create a cluster'}

@@ -258,7 +264,7 @@ function Banner(props: BannerProps) {
-
-
-

{props.meta.name}

+
+
+

{props.meta.name}

{upperFirst(props.meta.loader)} {' '} @@ -364,18 +323,18 @@ function ClusterCard(props: Cluster) { {props.instance.mods && `• ${props.mods} mods`} */}

-
- - ); } diff --git a/apps/frontend/src/ui/pages/cluster/ClusterGame.tsx b/apps/frontend/src/ui/pages/cluster/ClusterGame.tsx index fbfeac53..14fe16bb 100644 --- a/apps/frontend/src/ui/pages/cluster/ClusterGame.tsx +++ b/apps/frontend/src/ui/pages/cluster/ClusterGame.tsx @@ -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(); + const navigate = useNavigate(); - // TODO: Save cluster settings + const [unlisten, setUnlisten] = createSignal(); + const [startedAt, setStartedAt] = createSignal(); + 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 (

Game Running

- -

TODO

-
+
+
+ + Started: + {' '} + + + + +
+ +

+ PID: + {' '} + {pid() || 0} +

+
+ +
); } +ClusterGame.buildUrl = function (uuid: string, process_uuid: string): URLSearchParams { + return new URLSearchParams({ id: uuid, process_uuid }); +}; + export default ClusterGame; diff --git a/apps/frontend/src/ui/pages/cluster/ClusterLogs.tsx b/apps/frontend/src/ui/pages/cluster/ClusterLogs.tsx index ffdc42b8..4c5ae8c7 100644 --- a/apps/frontend/src/ui/pages/cluster/ClusterLogs.tsx +++ b/apps/frontend/src/ui/pages/cluster/ClusterLogs.tsx @@ -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() {
No logs were found.}> -
- - - -
+
diff --git a/apps/frontend/src/ui/pages/cluster/ClusterOverview.tsx b/apps/frontend/src/ui/pages/cluster/ClusterOverview.tsx index 7f100641..b63158f3 100644 --- a/apps/frontend/src/ui/pages/cluster/ClusterOverview.tsx +++ b/apps/frontend/src/ui/pages/cluster/ClusterOverview.tsx @@ -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 (

Overview

- setDeleteVisible(true)} /> + + + Folders and Files + } + disabled={editMode()} + children={( +
- setModalVisible(false)} - />, -
+
+ +
); } diff --git a/apps/frontend/src/ui/pages/cluster/ClusterRoot.tsx b/apps/frontend/src/ui/pages/cluster/ClusterRoot.tsx index a01824cc..386e56ad 100644 --- a/apps/frontend/src/ui/pages/cluster/ClusterRoot.tsx +++ b/apps/frontend/src/ui/pages/cluster/ClusterRoot.tsx @@ -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(); + 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 ( , 'Overview', '/'], - // TODO: Better way of checking mods - (cluster()?.meta.loader !== 'vanilla' ? [, 'Mods', '/mods'] : undefined), + (supportsMods(cluster?.()) ? [, 'Mods', '/mods'] : undefined), [, 'Screenshots', '/screenshots'], [, 'Worlds', '/worlds'], [, 'Logs', '/logs'], [, '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 = ; // : ; + + return [icon, `Process #${index + 1}`, '/game', ClusterGame.buildUrl(cluster()!.uuid, process_uuid)]; + }), + } + : {}), }} /> ); diff --git a/apps/frontend/src/ui/pages/cluster/ClusterSettings.tsx b/apps/frontend/src/ui/pages/cluster/ClusterSettings.tsx index e987a21b..d2cea237 100644 --- a/apps/frontend/src/ui/pages/cluster/ClusterSettings.tsx +++ b/apps/frontend/src/ui/pages/cluster/ClusterSettings.tsx @@ -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() { 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, }} /> diff --git a/apps/frontend/src/ui/pages/settings/SettingsRoot.tsx b/apps/frontend/src/ui/pages/settings/SettingsRoot.tsx index f6f1f5ba..179a43b1 100644 --- a/apps/frontend/src/ui/pages/settings/SettingsRoot.tsx +++ b/apps/frontend/src/ui/pages/settings/SettingsRoot.tsx @@ -40,7 +40,6 @@ function SettingsRoot(props: ParentProps) {
, 'General', '/'], diff --git a/apps/frontend/src/ui/pages/settings/launcher/SettingsAppearance.tsx b/apps/frontend/src/ui/pages/settings/launcher/SettingsAppearance.tsx index 5eabe453..cbd28ddf 100644 --- a/apps/frontend/src/ui/pages/settings/launcher/SettingsAppearance.tsx +++ b/apps/frontend/src/ui/pages/settings/launcher/SettingsAppearance.tsx @@ -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 ( @@ -32,7 +43,10 @@ function SettingsAppearance() { > settings.disable_animations = value} + onChecked={(value) => { + settings.disable_animations = value; + setShouldReload(true); + }} /> diff --git a/apps/frontend/src/ui/pages/settings/launcher/SettingsGeneral.tsx b/apps/frontend/src/ui/pages/settings/launcher/SettingsGeneral.tsx index a4f1944b..d7bed38d 100644 --- a/apps/frontend/src/ui/pages/settings/launcher/SettingsGeneral.tsx +++ b/apps/frontend/src/ui/pages/settings/launcher/SettingsGeneral.tsx @@ -52,7 +52,7 @@ function SettingsGeneral() { Folders and Files } > diff --git a/apps/frontend/src/utils/index.ts b/apps/frontend/src/utils/index.ts index 24b0f403..2f4d7b26 100644 --- a/apps/frontend/src/utils/index.ts +++ b/apps/frontend/src/utils/index.ts @@ -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; } diff --git a/apps/testing/src/main.rs b/apps/testing/src/main.rs index 7470f075..2f390385 100644 --- a/apps/testing/src/main.rs +++ b/apps/testing/src/main.rs @@ -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(); diff --git a/packages/core/src/api/cluster/mod.rs b/packages/core/src/api/cluster/mod.rs index b150a599..86cee05c 100644 --- a/packages/core/src/api/cluster/mod.rs +++ b/packages/core/src/api/cluster/mod.rs @@ -85,7 +85,7 @@ pub async fn list_grouped(clear: Option) -> crate::Result crate::Result>> { +pub async fn run_default(path: &ClusterPath) -> crate::Result>> { 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 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>> { + 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))] diff --git a/packages/core/src/api/processor.rs b/packages/core/src/api/processor.rs index c8b4069a..bdda3c1b 100644 --- a/packages/core/src/api/processor.rs +++ b/packages/core/src/api/processor.rs @@ -50,6 +50,13 @@ pub async fn get_running_clusters() -> crate::Result> { 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 { + 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> { @@ -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 { + 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"))?) +}