feat: Revamp Cluster overview page, make it less empty

This commit is contained in:
LynithDev 2024-08-02 12:30:27 +02:00
parent 7009f5db08
commit 32d27bc9fb
7 changed files with 317 additions and 242 deletions

View file

@ -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) */

View file

@ -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>

View file

@ -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 > * {

View file

@ -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 Button from '../components/base/Button';
import TextField from '../components/base/TextField';
import ClusterRoot from './cluster/ClusterRoot';
@ -276,43 +272,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 +281,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 +302,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 +313,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}
/>
</>
);
}

View file

@ -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/primitives';
import { secondsToWords, upperFirst } from '~utils/primitives';
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>
);
}

View file

@ -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 />}
>

View file

@ -23,25 +23,42 @@ export function getEnumMembers(obj: any): string[] {
});
}
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} second`;
else if (n < (60 * 60))
return `${Math.floor(n / 60)} minute`;
else if (n < (60 * 60 * 24))
return `${Math.floor(n / (60 * 60))} hour`;
else if (n < (60 * 60 * 24 * 7))
return `${Math.floor(n / (60 * 60 * 24))} day`;
else if (n < (60 * 60 * 24 * 30))
return `${Math.floor(n / (60 * 60 * 24 * 7))} week`;
else if (n < (60 * 60 * 24 * 365))
return `${Math.floor(n / (60 * 60 * 24 * 30))} month`;
else if (n < (60 * 60 * 24 * 365 * 10))
return `${Math.floor(n / (60 * 60 * 24 * 365))} year`;
return `${n} seconds`; // Hmmmmmm
return `${n} second`; // 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;
}
export default {