feat(cluster): add settings, screenshots, and worlds pages

This commit is contained in:
emirsassan 2025-05-28 12:56:44 +03:00
parent e92508c8de
commit 46cca44837
17 changed files with 530 additions and 32 deletions

View file

@ -22,6 +22,7 @@ macro_rules! collect_commands {
cluster::get_cluster_by_id,
settings::get_global_profile,
settings::get_profile_or_default,
other::open_dev_tools,
]

View file

@ -7,4 +7,12 @@ use crate::api::error::SerializableResult;
#[tauri::command]
pub async fn get_global_profile() -> SerializableResult<Model> {
Ok(setting_profiles::get_global_profile().await)
}
#[specta::specta]
#[tauri::command]
pub async fn get_profile_or_default(
name: Option<String>,
) -> SerializableResult<Model> {
Ok(setting_profiles::dao::get_profile_or_default(name.as_ref()).await?)
}

View file

@ -6,7 +6,7 @@ interface ScrollableContainerProps {
function ScrollableContainer(props: ScrollableContainerProps) {
return (
<div className="h-full h-screen w-[calc(100%+14px)] flex flex-1 flex-col overflow-hidden">
<div className="h-full h-screen w-full flex flex-1 flex-col overflow-hidden">
<OverlayScrollbarsComponent className="os-hide-horizontal-scrollbar relative flex-1">
<div className="absolute h-full w-[calc(100%-14px)] flex flex-col gap-2">
{props.children}

View file

@ -12,7 +12,7 @@ function SettingsRow(props: SettingsRowProps) {
return (
<div
className={`
flex flex-row items-center gap-3.5 rounded-xl p-4
flex flex-row items-center gap-3.5 rounded-xl p-4 my-2
${props.disabled ? 'bg-component-bg-disabled' : 'bg-page-elevated hover:bg-component-bg-hover'}
`}
>

View file

@ -0,0 +1,62 @@
import type { ClassNameString } from '@/types/global';
import type {
SelectProps as AriaComboBoxProps,
ListBoxItemProps as AriaListBoxItemProps
} from 'react-aria-components';
import {
Select as AriaComboBox,
Label as AriaLabel,
ListBox as AriaListBox,
ListBoxItem as AriaListBoxItem,
Popover as AriaPopover,
SelectValue
} from 'react-aria-components';
import { tv } from 'tailwind-variants';
import Button from './Button';
interface ComboBoxWrapperProps<T extends object> extends Omit<AriaComboBoxProps<T>, 'className'>, ClassNameString {
label?: string;
}
const dropdownStyles = tv({
base: [
"flex flex-col border border-component-bg-hover w-fit rounded-lg focus:outline-none"
]
})
function Dropdown<T extends object>({
className,
label,
children,
...rest
}: ComboBoxWrapperProps<T>) {
return (
<AriaComboBox
className={dropdownStyles({ className })}
{...rest}
>
{label && <AriaLabel>{label}</AriaLabel>}
<div className="flex">
<SelectValue />
<Button className="size-6">▼</Button>
</div>
<AriaPopover className="border border-orange-400">
<AriaListBox className="bg-blue-500 p-2 w-38">
{children}
</AriaListBox>
</AriaPopover>
</AriaComboBox>
);
}
export default Dropdown;
Dropdown.Item = ({
className,
...rest
}: AriaListBoxItemProps & ClassNameString) => (
<AriaListBoxItem
className={className}
{...rest}
/>
);

View file

@ -0,0 +1,39 @@
import type { Model, GameLoader } from '@/bindings.gen';
import FabricImage from '@/assets/logos/loaders/fabric.png';
import ForgeImage from '@/assets/logos/loaders/forge.png';
import NeoForgeImage from '@/assets/logos/loaders/neoforge.png';
import QuiltImage from '@/assets/logos/loaders/quilt.png';
import VanillaImage from '@/assets/logos/minecraft.png';
import type { RefAttributes } from 'react';
export function getLoaderLogoSrc(loader: Model | GameLoader): string {
const loaderName = (typeof loader === 'string' ? loader : loader.mc_loader)?.toLowerCase() as GameLoader;
const mapping: Record<GameLoader, string> = {
vanilla: VanillaImage,
fabric: FabricImage,
legacyfabric: FabricImage,
forge: ForgeImage,
neoforge: NeoForgeImage,
quilt: QuiltImage,
};
return mapping[loaderName];
}
type LoaderIconProp = RefAttributes<HTMLImageElement> & {
loader: GameLoader | undefined;
className?: string;
};
function LoaderIcon(props: LoaderIconProp) {
return (
<img
{...props}
alt={`${props.loader}'s logo`}
src={props.loader ? getLoaderLogoSrc(props.loader) : VanillaImage}
/>
);
}
export default LoaderIcon;

View file

@ -11,7 +11,7 @@ interface ModalProps extends DialogProps, ClassNameString { }
function Modal({ className, ...props }: ModalProps) {
return (
<ModalOverlay isDismissable className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<AriaDialog className="min-w-md flex flex-col gap-y-2 border border-white/5 rounded-lg bg-page p-4 text-center focus:outline-none" {...props} />
<AriaDialog className="" {...props} />
</ModalOverlay>
);
}

View file

@ -0,0 +1,42 @@
import { bindings } from "@/main";
import useCommand from "./useCommand";
import { useEffect, useState } from "react";
import type { Model } from "@/bindings.gen";
function useRecentCluster() {
const result = useCommand("getClusters", bindings.commands.getClusters);
const [cluster, setCluster] = useState<Model | undefined>();
useEffect(() => {
if (!result.data) return;
let mostRecentCluster: Model | undefined;
for (const c of result.data) {
if (!mostRecentCluster) {
mostRecentCluster = c;
continue;
}
const currentPlayed = mostRecentCluster.last_played;
const newPlayed = c.last_played;
if (typeof currentPlayed !== 'string' && typeof newPlayed === 'string') {
mostRecentCluster = c;
} else if (typeof currentPlayed === 'string' && typeof newPlayed === 'string') {
const playedAt = new Date(currentPlayed);
const clusterPlayedAt = new Date(newPlayed);
if (clusterPlayedAt > playedAt) {
mostRecentCluster = c;
}
}
}
setCluster(mostRecentCluster);
}, [result.data]);
return cluster;
}
export default useRecentCluster;

View file

@ -1,4 +1,5 @@
import Button from '@/components/base/Button';
import Dropdown from '@/components/base/Dropdown';
import { TextField } from '@/components/base/TextField';
import Modal from '@/components/overlay/Modal';
import { createFileRoute } from '@tanstack/react-router';
@ -22,6 +23,11 @@ function RouteComponent() {
<p>sadsadsad</p>
</Modal>
</Modal.Trigger>
{/* <Dropdown>
<Dropdown.Item>Slmalr</Dropdown.Item>
<Dropdown.Item>Slmalr 2</Dropdown.Item>
</Dropdown> */}
</div>
);
}

View file

@ -2,6 +2,16 @@ import useCommand from '@/hooks/useCommand'
import { bindings } from '@/main'
import { createFileRoute } from '@tanstack/react-router'
import Sidebar from '../settings/route'
import Button from '@/components/base/Button'
import { Edit02Icon, FolderIcon, ImagePlusIcon, Share07Icon, Tool02Icon, Trash01Icon } from '@untitled-theme/icons-react'
import LoaderIcon from '@/components/launcher/LoaderIcon'
import { Show } from '@/components/base/Show'
import { TextField } from '@/components/base/TextField'
import type { Model } from '@/bindings.gen'
import ScrollableContainer from '@/components/ScrollableContainer'
import { useState, type Dispatch, type SetStateAction } from 'react'
import SettingsRow from '@/components/SettingsRow'
import ToggleButton from '@/components/base/ToggleButton'
export const Route = createFileRoute('/app/cluster/')({
component: RouteComponent
@ -9,15 +19,183 @@ export const Route = createFileRoute('/app/cluster/')({
function RouteComponent() {
const { id } = Route.useSearch()
const [newCover, setNewCover] = useState<string>("")
const [newName, setNewName] = useState<string>("")
// dumbass fix ik
const cluster = useCommand("getClusterById", () => bindings.commands.getClusterById(Number(id.toString()) as unknown as bigint))
return (
<Sidebar.Page>
<div className="h-full">
<pre>{JSON.stringify(cluster, null, 2)}</pre>
</div>
<h1>Overview</h1>
<ScrollableContainer>
<div className='h-full'>
<Banner
cluster={cluster.data}
newCover={newCover}
setNewCover={setNewCover}
editMode={false}
newName={newName}
setNewName={setNewName}
refetch={cluster.refetch}
/>
<SettingsRow.Header>Folders and Files</SettingsRow.Header>
<SettingsRow
children={(
<Button
color="primary"
children="Open"
isDisabled={false}
/>
)}
description={"burada path var işte yersen"}
disabled={false}
icon={<FolderIcon />}
title="Cluster Folder"
/>
<SettingsRow.Header>Cluster Actions</SettingsRow.Header>
<SettingsRow
children={(
<ToggleButton color='secondary'>Edit</ToggleButton>
)}
description="Edit the cluster name and cover image."
icon={<Edit02Icon />}
title="Edit Cluster"
/>
<SettingsRow
children={(
<Button
color="danger"
children="Delete"
isDisabled={false}
/>
)}
description="Delete this cluster and all its data."
disabled={false}
icon={<Trash01Icon />}
title="Delete Cluster"
/>
<SettingsRow
children={(
<Button
color="secondary"
children="Repair"
/>
)}
description="Verifies whether all assets, libraries and natives were properly installed."
icon={<Tool02Icon />}
title="Verify Cluster"
/>
</div>
</ScrollableContainer>
</Sidebar.Page>
)
}
interface BannerProps {
cluster: Model | null | undefined;
editMode: boolean;
newName: string;
setNewName: Dispatch<SetStateAction<string>>;
newCover: string;
setNewCover: Dispatch<SetStateAction<string>>;
refetch: () => void;
}
function Banner(props: BannerProps) {
// async function launchFilePicker() {
// const selected = await dialog.open({
// multiple: false,
// directory: false,
// filters: [{
// name: 'Image',
// extensions: ['png', 'jpg', 'jpeg', 'webp'],
// }],
// });
// if (selected === null)
// return;
// props.setNewCover(selected);
// }
function updateName(name: string) {
if (name.length > 30 || name.length <= 0)
return;
props.setNewName(name);
}
return (
<div className="h-37 flex flex-row gap-x-2.5 rounded-xl bg-page-elevated p-2.5">
<div className="relative aspect-ratio-video h-full min-w-57 w-57 overflow-hidden border border-component-bg/10 rounded-lg">
<Show when={props.editMode}>
<div
className="absolute z-1 h-full w-full flex items-center justify-center bg-black/50 opacity-50 hover:opacity-100"
// onClick={launchFilePicker}
>
<ImagePlusIcon className="h-12 w-12" />
</div>
</Show>
{/* <ClusterCover class="h-full w-full object-cover" cluster={props.cluster} override={props.newCover()} /> */}
<img src='https://github.com/emirsassan.png' />
</div>
<div className="w-full flex flex-col justify-between gap-y-.5 overflow-hidden text-fg-primary">
<div>
<Show
fallback={
<h2 className="break-words text-wrap text-2xl">{props.cluster?.name}</h2>
}
when={props.editMode}
>
<TextField
className="text-xl font-bold"
onChange={e => updateName(e.target.value)}
placeholder={props.cluster?.name}
/>
</Show>
</div>
<div className="flex flex-1 flex-row">
<div
className={"flex flex-1 flex-col items-start justify-between " + props.editMode ? "text-fg-primary-disabled" : ""}
>
<span className="flex flex-row items-center gap-x-1">
<LoaderIcon
className="w-5"
loader={props.cluster?.mc_loader}
/>
<span>{props.cluster?.mc_version}</span>
<span>{props.cluster?.mc_loader || 'unknown'}</span>
{props.cluster?.mc_loader_version && <span>{props.cluster.mc_loader_version}</span>}
</span>
<span
className={"text-xs text-fg-secondary " + props.editMode ? "text-fg-secondary-disabled" : ""}
>
Played for
{' '}
<b>{props.cluster?.overall_played || 0}</b>
.
</span>
</div>
<div className="flex flex-row items-end gap-x-2.5 *:h-8">
<Button children="Launch" />
<Button
color="secondary"
children={<Share07Icon />}
// disabled={props.editMode()}
isDisabled={true}
/>
</div>
</div>
</div>
</div>
);
}

View file

@ -1,6 +1,8 @@
import { createFileRoute, Outlet } from '@tanstack/react-router'
import Sidebar from '../settings/route'
import { EyeIcon, File06Icon, Globe04Icon, Image03Icon, PackagePlusIcon, Settings04Icon } from '@untitled-theme/icons-react'
import useCommand from '@/hooks/useCommand'
import { bindings } from '@/main'
type ClusterSearch = {
id: bigint
@ -16,6 +18,12 @@ export const Route = createFileRoute('/app/cluster')({
})
function RouteComponent() {
const { id } = Route.useSearch()
const cluster = useCommand("getClusterById", () => bindings.commands.getClusterById(Number(id.toString()) as unknown as bigint))
const isModded = cluster.data?.mc_loader != "vanilla"
return (
<div className="h-full flex flex-row overflow-hidden">
<div className="flex-shrink-0 w-72 flex flex-col pt-8 pr-7 pb-8">
@ -23,12 +31,13 @@ function RouteComponent() {
base="/app/cluster"
links={{
'Cluster Settings': [
[<EyeIcon />, 'Overview', '/'],
[<PackagePlusIcon />, 'Mods', '/mods'],
[<Image03Icon />, 'Screenshots', '/screenshots'],
[<Globe04Icon />, 'Worlds', '/worlds'],
[<File06Icon />, 'Logs', '/logs'],
[<Settings04Icon />, 'Game Settings', '/settings'],
// if someone has a better way to do this, please let me know
[<EyeIcon />, 'Overview', '/?id=' + id],
(isModded ? [<PackagePlusIcon />, 'Mods', '/mods?id=' + id] : undefined),
[<Image03Icon />, 'Screenshots', '/screenshots?id=' + id],
[<Globe04Icon />, 'Worlds', '/worlds?id=' + id],
[<File06Icon />, 'Logs', '/logs?id=' + id],
[<Settings04Icon />, 'Game Settings', '/settings?id=' + id],
],
}}
/>

View file

@ -0,0 +1,46 @@
import { createFileRoute } from '@tanstack/react-router'
import Sidebar from '../settings/route';
import ScrollableContainer from '@/components/ScrollableContainer';
import { Show } from '@/components/base/Show';
export const Route = createFileRoute('/app/cluster/screenshots')({
component: RouteComponent,
})
function RouteComponent() {
const arr = [1, 2, 3, 4, 5, 6]
return (
<Sidebar.Page>
<h1>Screenshots</h1>
<ScrollableContainer>
<div className="h-full">
<Show
fallback={<div className="text-border/400">No screenshots found. Press F2 in game to take a screenshot!</div>}
when={true}
>
<div className="grid grid-cols-[repeat(auto-fill,minmax(350px,1fr))] w-full transform-gpu gap-2">
{arr.map((data) => (
<ScreenshotEntry key={data} path={`screenshot-${data}.png`} cluster_path="cluster" />
))}
</div>
</Show>
</div>
</ScrollableContainer>
</Sidebar.Page>
)
}
function ScreenshotEntry(props: { path: string; cluster_path: string }) {
function onClick() {
//screen shot viewer overlay
}
return (
<div
className="flex flex-col items-center gap-3 rounded-xl bg-component-bg p-3 active:bg-component-bg-pressed hover:bg-component-bg-hover hover:opacity-80"
onClick={onClick}
>
<img alt={props.path} className="aspect-video w-full rounded-lg" src="https://github.com/emirsassan.png" />
</div>
);
}

View file

@ -0,0 +1,26 @@
import { createFileRoute } from '@tanstack/react-router'
import Sidebar from '../settings/route'
import ScrollableContainer from '@/components/ScrollableContainer'
import useCommand from '@/hooks/useCommand'
import { bindings } from '@/main'
export const Route = createFileRoute('/app/cluster/settings')({
component: RouteComponent,
})
function RouteComponent() {
const { id } = Route.useSearch()
const result = useCommand("getProfileOrDefault", () => bindings.commands.getProfileOrDefault(id.toString()))
return (
<Sidebar.Page>
<h1>Settings</h1>
<ScrollableContainer>
<div className="h-full">
<pre>{JSON.stringify(result.data, null, 2)}</pre>
</div>
</ScrollableContainer>
</Sidebar.Page>
)
}

View file

@ -0,0 +1,20 @@
import { createFileRoute } from '@tanstack/react-router'
import Sidebar from '../settings/route'
import ScrollableContainer from '@/components/ScrollableContainer'
export const Route = createFileRoute('/app/cluster/worlds')({
component: RouteComponent,
})
function RouteComponent() {
return (
<Sidebar.Page>
<h1>Worlds</h1>
<ScrollableContainer>
<div className="h-full">
</div>
</ScrollableContainer>
</Sidebar.Page>
)
}

View file

@ -3,10 +3,12 @@ import type { Model } from '@/bindings.gen';
import Button from '@/components/base/Button';
import useCommand from '@/hooks/useCommand';
import { bindings } from '@/main';
import { useIsFetching, useQueryClient } from '@tanstack/react-query';
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
import DefaultInstancePhoto from "@/assets/images/default_instance_cover.jpg"
import Modal from '@/components/overlay/Modal';
import { Server01Icon } from '@untitled-theme/icons-react';
import HeaderImage from "@/assets/images/default_instance_cover.jpg";
export const Route = createFileRoute('/app/')({
component: RouteComponent,
@ -30,7 +32,7 @@ function RouteComponent() {
</div>
<div className='flex flex-col'>
<ClusterGroup clusters={result.data} />
<ClusterGroup clusters={result.data} isFetching={result.isFetching} />
</div>
</div>
);
@ -108,8 +110,6 @@ function Banner() {
}
function ClusterCreate() {
const queryClient = useQueryClient();
const result = useCommand("createCluster", () => bindings.commands.createCluster({
icon_url: "asd",
mc_loader: "vanilla",
@ -128,30 +128,59 @@ function ClusterCreate() {
alert(result.error.message)
return;
}
queryClient.invalidateQueries({
queryKey: ["getClusters"],
})
}
return (
<>
<Button
children="New Cluster"
onClick={testThingy}
/>
<Modal.Trigger>
<Button
children="New Cluster"
// onClick={testThingy}
/>
<Modal>
<div className="min-w-sm flex flex-col rounded-lg bg-page text-center">
<div className="theme-OneLauncher-Dark relative h-25 flex">
<div className="absolute left-0 top-0 h-full w-full">
<img alt="Header Image" className="h-full w-full rounded-t-lg" src={HeaderImage} />
</div>
<div
className="absolute left-0 top-0 h-full flex flex-row items-center justify-start gap-x-4 bg-[radial-gradient(at_center,#00000077,transparent)] px-10"
>
<Server01Icon className="h-8 w-8 text-fg-primary" />
<div className="flex flex-col items-start justify-center">
<h1 className="h-10 text-fg-primary -mt-2">New Cluster</h1>
<span className="text-fg-primary">asd</span>
</div>
</div>
</div>
<div className="flex flex-col border border-white/5 rounded-b-lg">
<div className="p-3">
<pre>asdasdasdasds</pre>
</div>
<div className="flex flex-row justify-end gap-x-2 p-3 pt-0">
<Button
color="primary"
children="Create"
onClick={testThingy}
/>
</div>
</div>
</div>
</Modal>
</Modal.Trigger>
</>
)
}
interface ClusterGroupProps {
clusters: Model[] | undefined;
isFetching?: boolean;
}
function ClusterGroup(props: ClusterGroupProps) {
const isFetching = useIsFetching();
if (isFetching) {
if (props.isFetching) {
return (
<div className='flex items-center justify-center h-full'>
<div className="w-8 h-8 border-4 border-brand rounded-full border-t-transparent animate-spin" />

View file

@ -66,10 +66,8 @@ function Sidebar(props: SidebarProps) {
navigate({ to: url })
}
function isActive(link: string, params: URLSearchParams | undefined) {
const pathnameMatch = location === `${props.base}${link}` || `${location}/` === `${props.base}${link}`;
const paramsMatch = params ? location.endsWith(params.toString()) : true;
return pathnameMatch && paramsMatch;
function isActive(link: string, _params: URLSearchParams | undefined) {
return location === `${props.base}${link}` || `${location}/` === `${props.base}${link}`;
}
return (
@ -109,7 +107,7 @@ function Sidebar(props: SidebarProps) {
interface SidebarPageProps {
className?: string;
children: JSX.Element;
children: JSX.Element | JSX.Element[];
}
Sidebar.Page = function (props: SidebarPageProps) {

View file

@ -6,6 +6,7 @@ use crate::store::State;
pub mod dao {
use onelauncher_entity::setting_profiles;
use sea_orm::IntoActiveModel;
use sea_orm::{ActiveModelTrait, EntityTrait};
use crate::error::{DaoError, LauncherResult};
@ -57,6 +58,39 @@ pub mod dao {
Ok(profile.insert(db).await?)
}
pub async fn update_profile_by_name<B>(
name: &str,
block: B,
) -> LauncherResult<setting_profiles::Model>
where B: AsyncFnOnce(setting_profiles::ActiveModel) -> LauncherResult<setting_profiles::ActiveModel> {
let state = State::get().await?;
let db = &state.db;
let model = get_profile_by_name(name).await?.ok_or(DaoError::NotFound)?;
let model = block(model.into_active_model()).await?;
let model = model.update(db).await?;
Ok(model)
}
/// Updates an existing profile in the database.
pub async fn update_profile<B>(
profile: &mut setting_profiles::Model,
block: B,
) -> LauncherResult<&mut setting_profiles::Model>
where B: AsyncFnOnce(setting_profiles::ActiveModel) -> LauncherResult<setting_profiles::ActiveModel> {
let state = State::get().await?;
let db = &state.db;
let model = profile.clone().into_active_model();
let model = block(model).await?;
let model = model.update(db).await?;
*profile = model;
Ok(profile)
}
pub async fn delete_profile_by_name(name: &str) -> LauncherResult<()> {
let state = State::get().await?;
let db = &state.db;