feat: global game settings and notifications

notification system needs some work still
This commit is contained in:
emirsassan 2025-06-26 20:48:48 +03:00
parent 1a099f6c3b
commit 855d421eaa
15 changed files with 515 additions and 48 deletions

View file

@ -1,5 +1,6 @@
import type { ToOptions } from '@tanstack/react-router';
import { bindings } from '@/main';
import useNotifications from '@/hooks/useNotification';
import { useCommand } from '@onelauncher/common';
import { Button, Menu } from '@onelauncher/common/components';
import { Link } from '@tanstack/react-router';
@ -8,9 +9,12 @@ import { MenuTrigger } from 'react-aria-components';
import OneLauncherText from '../assets/logos/onelauncher_text.svg';
import PlayerHead from './content/PlayerHead';
import AccountPopup from './overlay/popups/AccountPopup';
import NotificationPopup from './overlay/popups/NotificationPopup';
function Navbar() {
const defaultUser = useCommand('getDefaultUser', () => bindings.core.getDefaultUser(false));
const { list } = useNotifications();
const notificationCount = Object.keys(list).length;
return (
<div className="h-15 min-h-15 flex flex-row items-center *:flex-1">
@ -37,24 +41,19 @@ function Navbar() {
{/* Notification Manager Button */}
<div className="relative">
<MenuTrigger>
<Button color="ghost" size="icon">
<Button className="relative" color="ghost" size="icon">
<Bell01Icon />
{notificationCount > 0 && (
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full h-5 w-5 flex items-center justify-center font-medium">
{notificationCount > 99 ? '99+' : notificationCount}
</span>
)}
</Button>
<Menu.Popover placement="bottom right">
<Menu>
<Menu.Item>Hello World</Menu.Item>
</Menu>
<NotificationPopup />
</Menu.Popover>
</MenuTrigger>
{/* <NotificationPopup
className="mt-2"
mount={notificationButtonContainer}
ref={el => el.classList.add('right-0')}
setVisible={setNotificationMenuOpen}
visible={notificationMenuOpen}
/> */}
</div>
<MenuTrigger>

View file

@ -262,9 +262,9 @@ function ClusterInformation({
<Option header="Loader">
{/* TODO: fixme */}
<Dropdown defaultSelectedKey="vanilla" onSelectionChange={e => onClusterLoaderChange(e)}>
<Dropdown defaultSelectedKey="vanilla" onSelectionChange={e => onClusterLoaderChange(e.toString() as GameLoader)}>
{LOADERS.map(loader => (
<Dropdown.Item key={loader}>
<Dropdown.Item id={loader} key={loader}>
<div className="flex flex-row">
<LoaderIcon className="size-5" loader={loader as GameLoader} />
{' '}

View file

@ -0,0 +1,132 @@
import type { NotificationData } from '@/hooks/useNotification';
import useNotifications from '@/hooks/useNotification';
import { Button } from '@onelauncher/common/components';
import { XIcon } from '@untitled-theme/icons-react';
import { useCallback, useEffect, useState } from 'react';
import { twMerge } from 'tailwind-merge';
interface ToastNotification extends NotificationData {
id: string;
timestamp: number;
}
interface NotificationToastProps {
notification: ToastNotification;
onRemove: (id: string) => void;
}
function NotificationToast({ notification, onRemove }: NotificationToastProps) {
const [isVisible, setIsVisible] = useState(false);
const [isRemoving, setIsRemoving] = useState(false);
const handleRemove = useCallback(() => {
setIsRemoving(true);
setTimeout(() => {
onRemove(notification.id);
}, 300);
}, [notification.id, onRemove]);
useEffect(() => {
// Trigger entrance animation
const timer = setTimeout(() => setIsVisible(true), 50);
return () => clearTimeout(timer);
}, []);
useEffect(() => {
// Auto-remove after 5 seconds if no progress indicator
if (notification.fraction === undefined) {
const timer = setTimeout(handleRemove, 5000);
return () => clearTimeout(timer);
}
}, [notification.fraction, handleRemove]);
return (
<div
className={twMerge(
'transform transition-all duration-300 ease-in-out',
'bg-component-bg border border-component-border rounded-lg shadow-lg',
'p-4 mb-3 max-w-sm',
isVisible && !isRemoving ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0',
)}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-fg-primary text-sm truncate">
{notification.title}
</h4>
<p className="text-fg-secondary text-xs mt-1 break-words">
{notification.message}
</p>
</div>
<Button
className="flex-shrink-0 h-6 w-6 p-0"
color="ghost"
onClick={handleRemove}
size="icon"
>
<XIcon className="h-3 w-3" />
</Button>
</div>
{notification.fraction !== undefined && (
<div className="w-full bg-component-bg-hover rounded-full h-2 mt-3">
<div
className="bg-brand h-2 rounded-full transition-all duration-300"
style={{ width: `${Math.max(0, Math.min(100, notification.fraction))}%` }}
/>
</div>
)}
</div>
);
}
function NotificationToasts() {
const { list } = useNotifications();
const [toasts, setToasts] = useState<Array<ToastNotification>>([]);
useEffect(() => {
const currentToasts = Object.entries(list).map(([id, data]) => ({
id,
...data,
timestamp: Date.now(),
}));
setToasts((prev) => {
const existingIds = new Set(prev.map(t => t.id));
const newToasts = currentToasts.filter(toast => !existingIds.has(toast.id));
const updatedToasts = prev.map((toast) => {
const updated = currentToasts.find(t => t.id === toast.id);
return updated ? { ...toast, ...updated } : toast;
});
const currentIds = new Set(currentToasts.map(t => t.id));
const filteredToasts = updatedToasts.filter(toast => currentIds.has(toast.id));
return [...filteredToasts, ...newToasts];
});
}, [list]);
const removeToast = (id: string) => {
setToasts(prev => prev.filter(toast => toast.id !== id));
};
if (toasts.length === 0)
return null;
return (
<div className="fixed bottom-4 right-4 z-50 pointer-events-none">
<div className="pointer-events-auto">
{toasts.map(toast => (
<NotificationToast
key={toast.id}
notification={toast}
onRemove={removeToast}
/>
))}
</div>
</div>
);
}
export default NotificationToasts;

View file

@ -0,0 +1,98 @@
import type { NotificationData } from '@/hooks/useNotification';
import useNotifications from '@/hooks/useNotification';
import { Button, Menu } from '@onelauncher/common/components';
import { XIcon } from '@untitled-theme/icons-react';
interface NotificationItemProps {
id: string;
data: NotificationData;
onRemove: (id: string) => void;
}
function NotificationItem({ id, data, onRemove }: NotificationItemProps) {
return (
<div className="flex flex-col gap-2 p-3 bg-component-bg border border-component-border rounded-lg">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-fg-primary text-sm truncate">
{data.title}
</h4>
<p className="text-fg-secondary text-xs mt-1 break-words">
{data.message}
</p>
</div>
<Button
className="flex-shrink-0 h-6 w-6 p-0"
color="ghost"
onClick={() => onRemove(id)}
size="icon"
>
<XIcon className="h-3 w-3" />
</Button>
</div>
{data.fraction !== undefined && (
<div className="w-full bg-component-bg-hover rounded-full h-2">
<div
className="bg-brand h-2 rounded-full transition-all duration-300"
style={{ width: `${Math.max(0, Math.min(100, data.fraction))}%` }}
/>
</div>
)}
</div>
);
}
function NotificationPopup() {
const { list, clear, remove } = useNotifications();
const notificationEntries = Object.entries(list);
const removeNotification = (id: string) => {
remove(id);
};
if (notificationEntries.length === 0)
return (
<Menu className="min-w-80 max-w-96">
<Menu.Item>
<div className="p-4 text-center">
<p className="text-fg-secondary text-sm">No notifications</p>
</div>
</Menu.Item>
</Menu>
);
return (
<Menu className="min-w-80 max-w-96 max-h-96 overflow-y-auto">
<Menu.Item>
<div className="flex items-center justify-between p-3 border-b border-component-border">
<h3 className="font-semibold text-fg-primary">Notifications</h3>
{notificationEntries.length > 0 && (
<Button
className="text-xs"
color="ghost"
onClick={clear}
size="normal"
>
Clear All
</Button>
)}
</div>
</Menu.Item>
{notificationEntries.map(([id, data]) => (
<Menu.Item key={id}>
<div className="p-2">
<NotificationItem
data={data}
id={id}
onRemove={removeNotification}
/>
</div>
</Menu.Item>
))}
</Menu>
);
}
export default NotificationPopup;

View file

@ -0,0 +1,81 @@
import type { UnlistenFn } from '@tauri-apps/api/event';
import type { ReactNode } from 'react';
import { bindings } from '@/main';
import { randomString } from '@/utils/index';
import { createContext, useContext, useEffect, useState } from 'react';
export interface NotificationData {
title: string;
message: string;
fraction?: number | undefined;
}
type Notifications = Record<string, NotificationData>;
export interface HookReturn {
list: Notifications;
set: (id: string, data: NotificationData) => void;
create: (data: NotificationData) => void;
remove: (id: string) => void;
clear: () => void;
}
interface NotificationContextValue {
notifications: Notifications;
setNotifications: React.Dispatch<React.SetStateAction<Notifications>>;
}
const NotificationContext = createContext<NotificationContextValue | undefined>(undefined);
interface NotificationProviderProps {
children: ReactNode;
}
export function NotificationProvider({ children }: NotificationProviderProps) {
const [notifications, setNotifications] = useState<Notifications>({});
bindings.events.ingress.on((e) => {
setNotifications(prev => ({
...prev,
[e.id]: {
title: `${e.ingress_type}`,
message: e.message,
},
}));
}).then(u => u());
const ctx = {
notifications,
setNotifications,
};
return (
<NotificationContext.Provider value={ctx}>
{children}
</NotificationContext.Provider>
);
}
function useNotifications(): HookReturn {
const context = useContext(NotificationContext);
if (!context)
throw new Error('useNotifications must be used within a NotificationProvider');
const { notifications, setNotifications } = context;
const ctx: HookReturn = {
list: notifications,
set: (id, data) => setNotifications(prev => ({ ...prev, [id]: data })),
create: data => ctx.set(randomString(6), data),
remove: id => setNotifications((prev) => {
const { [id]: removed, ...rest } = prev;
return rest;
}),
clear: () => setNotifications({}),
};
return ctx;
}
export default useNotifications;

View file

@ -0,0 +1,20 @@
import { useLocation } from '@tanstack/react-router';
import { useEffect, useRef } from 'react';
export default function usePopState(onRouteChange: void | any) {
const location = useLocation();
const prevLocationRef = useRef(location);
useEffect(() => {
const prevLocation = prevLocationRef.current;
// Only run if the pathname actually changed
if (prevLocation.pathname !== location.pathname)
onRouteChange?.({
from: prevLocation,
to: location,
});
prevLocationRef.current = location;
}, [location, onRouteChange]);
}

View file

@ -4,8 +4,9 @@ import { createRouter, RouterProvider } from '@tanstack/react-router';
import { StrictMode } from 'react';
import ReactDOM from 'react-dom/client';
import { createTauRPCProxy } from './bindings.gen';
import { routeTree } from './routeTree.gen';
import { NotificationProvider } from './hooks/useNotification';
import { routeTree } from './routeTree.gen';
import './fonts';
import 'overlayscrollbars/overlayscrollbars.css';
import './styles/global.css';
@ -55,7 +56,9 @@ if (rootElement && !rootElement.innerHTML) {
root.render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<NotificationProvider>
<RouterProvider router={router} />
</NotificationProvider>
</QueryClientProvider>
</StrictMode>,
);

View file

@ -1,5 +1,7 @@
import type { HookReturn } from '@/hooks/useNotification';
import type { QueryClient } from '@tanstack/react-query';
import type { NavigateOptions, ToOptions } from '@tanstack/react-router';
import NotificationToasts from '@/components/overlay/NotificationToasts';
import WindowFrame from '@/components/WindowFrame';
import { AnimatedOutletProvider } from '@onelauncher/common/components';
import { createRootRouteWithContext, Outlet, useRouter } from '@tanstack/react-router';
@ -34,6 +36,7 @@ function RootRoute() {
<Outlet />
<NotificationToasts />
<TanStackRouterDevtools />
</div>
</RouterProvider>

View file

@ -1,4 +1,7 @@
import Modal from '@/components/overlay/Modal';
import useNotifications from '@/hooks/useNotification';
import { bindings } from '@/main';
import { useCommand } from '@onelauncher/common';
import { Button, Dropdown, TextField } from '@onelauncher/common/components';
import { createFileRoute } from '@tanstack/react-router';
@ -7,6 +10,31 @@ export const Route = createFileRoute('/app/browser/')({
});
function RouteComponent() {
const { create, list, clear } = useNotifications();
const result = useCommand('read_settings', bindings.core.readSettings);
const addSimpleNotif = () => {
create({
message: 'This is a simple notification message',
title: 'Simple Notification',
});
};
const addProgressNotif = () => {
create({
message: 'Download in progress...',
title: 'Downloading File',
fraction: Math.floor(Math.random() * 100),
});
};
const addErrorNotif = () => {
create({
message: 'Something went wrong while processing your request',
title: 'Error',
});
};
return (
<div>
<p>asdasdas</p>
@ -26,6 +54,31 @@ function RouteComponent() {
<Dropdown.Item>Slmalr</Dropdown.Item>
<Dropdown.Item>Slmalr 2</Dropdown.Item>
</Dropdown>
<div className="flex gap-2 flex-wrap">
<Button onClick={addSimpleNotif}>Add Simple Notification</Button>
<Button onClick={addProgressNotif}>Add Progress Notification</Button>
<Button onClick={addErrorNotif}>Add Error Notification</Button>
<Button onClick={clear}>Clear All Notifications</Button>
</div>
<div className="mt-4">
<h3 className="font-semibold mb-2">
Current Notifications (
{Object.keys(list).length}
):
</h3>
<pre className="text-xs bg-component-bg-hover p-2 rounded overflow-auto max-h-40">
{JSON.stringify(list, null, 2)}
</pre>
</div>
<div className="mt-4">
<h3>Settings</h3>
<pre className="text-xs bg-component-bg-hover p-2 rounded overflow-auto max-h-40">
{JSON.stringify(result.data, null, 2)}
</pre>
</div>
</div>
);
}

View file

@ -37,11 +37,6 @@ function RouteComponent() {
subscribed: false,
});
bindings.events.process.on((e) => {
// eslint-disable-next-line no-console -- ok
console.log(e);
});
function handleEditMode() {
if (edit)
editing.refetch();
@ -192,7 +187,12 @@ function Banner({
</Show>
{/* <ClusterCover class="h-full w-full object-cover" cluster={props.cluster} override={props.newCover()} /> */}
<img src={image()} />
<img
onError={(e) => {
(e.target as HTMLImageElement).src = DefaultInstancePhoto;
}}
src={image()}
/>
</div>
<div className="w-full flex flex-col justify-between gap-y-.5 overflow-hidden text-fg-primary">

View file

@ -1,4 +1,4 @@
import type { ClusterModel, IngressType } from '@/bindings.gen';
import type { ClusterModel } from '@/bindings.gen';
import DefaultBanner from '@/assets/images/default_banner.png';
import DefaultInstancePhoto from '@/assets/images/default_instance_cover.jpg';
import { NewClusterCreate } from '@/components/launcher/cluster/ClusterCreation';
@ -6,10 +6,10 @@ import useRecentCluster from '@/hooks/useCluster';
import { bindings } from '@/main';
import { upperFirst } from '@/utils';
import { useCommand } from '@onelauncher/common';
import { Button, Show, TextField } from '@onelauncher/common/components';
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router';
import { Button, Show } from '@onelauncher/common/components';
import { createFileRoute, Link } from '@tanstack/react-router';
import { convertFileSrc } from '@tauri-apps/api/core';
import { PlayIcon, Server01Icon } from '@untitled-theme/icons-react';
import { PlayIcon } from '@untitled-theme/icons-react';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
export const Route = createFileRoute('/app/')({
@ -69,6 +69,9 @@ function Banner() {
<img
alt="Default Banner"
className="top-0 left-0 h-full rounded-xl w-full object-cover"
onError={(e) => {
(e.target as HTMLImageElement).src = DefaultBanner;
}}
src={cluster?.icon_url || DefaultBanner}
/>
</div>
@ -174,12 +177,15 @@ function ClusterCard({
const image = () => {
const url = icon_url;
if (url === null)
if (!url)
return DefaultInstancePhoto;
return convertFileSrc(url);
};
// eslint-disable-next-line no-console -- ok
console.log(image());
return (
<>
<Link
@ -196,13 +202,18 @@ function ClusterCard({
>
<img
className="h-full w-full object-cover"
onError={(e) => {
(e.target as HTMLImageElement).src = DefaultInstancePhoto;
}}
src={image()}
/>
</div>
</div>
<div className="z-10 flex flex-row items-center justify-between gap-x-3 p-3">
<div className="h-full flex flex-col gap-1.5 overflow-hidden">
<p className="h-4 text-ellipsis whitespace-nowrap font-medium">{name}</p>
<p className="h-4 text-ellipsis whitespace-nowrap font-medium">
{name}
</p>
<p className="h-4 text-xs">
{mc_loader}
{' '}

View file

@ -1,5 +1,6 @@
import DiscordIcon from '@/assets/logos/discord.svg';
import SettingsRow from '@/components/SettingsRow';
import usePopState from '@/hooks/usePopState';
import { bindings } from '@/main';
import { useCommand } from '@onelauncher/common';
import { Button, Switch } from '@onelauncher/common/components';
@ -14,7 +15,16 @@ export const Route = createFileRoute('/app/settings/')({
});
function RouteComponent() {
const _result = useCommand('getGlobalProfile', bindings.core.getGlobalProfile);
const result = useCommand('readSettings', bindings.core.readSettings);
const save = useCommand('writeSettings', () => bindings.core.writeSettings(result.data!), {
enabled: false,
subscribed: false,
});
usePopState(() => {
save.refetch();
});
const openLauncherDir = async () => {
const dir = await dataDir();
@ -32,7 +42,13 @@ function RouteComponent() {
icon={<img className="w-6 invert-100" src={DiscordIcon} />}
title="Discord RPC"
>
<Switch />
<Switch
defaultSelected={result.data?.discord_enabled}
onChange={(val) => {
if (result.data)
result.data.discord_enabled = val;
}}
/>
</SettingsRow>
<SettingsRow

View file

@ -1,5 +1,9 @@
import type { SettingProfileModel } from '@/bindings.gen';
import ScrollableContainer from '@/components/ScrollableContainer';
import SettingsRow from '@/components/SettingsRow';
import usePopState from '@/hooks/usePopState';
import { bindings } from '@/main';
import { useCommand } from '@onelauncher/common';
import { Switch, TextField } from '@onelauncher/common/components';
import { createFileRoute } from '@tanstack/react-router';
import { Database01Icon, FilePlus02Icon, FileX02Icon, LayoutTopIcon, Maximize01Icon, ParagraphWrapIcon, XIcon } from '@untitled-theme/icons-react';
@ -10,13 +14,24 @@ export const Route = createFileRoute('/app/settings/minecraft')({
});
function RouteComponent() {
const result = useCommand('readSettings', bindings.core.readSettings);
const save = useCommand('writeSettings', () => bindings.core.writeSettings(result.data!), {
enabled: false,
subscribed: false,
});
usePopState(() => {
save.refetch();
});
return (
<Sidebar.Page>
<ScrollableContainer>
<div className="h-full">
<h1>Minecraft Settings</h1>
<GameSettings />
<GameSettings settings={result.data!.global_game_settings} />
{/* <LauncherSettings /> */}
@ -27,7 +42,13 @@ function RouteComponent() {
);
}
export function GameSettings() {
interface GameSettingProps {
settings: SettingProfileModel;
}
export function GameSettings(props: GameSettingProps) {
const { settings } = props;
return (
<>
<SettingsRow.Header>Game</SettingsRow.Header>
@ -37,7 +58,12 @@ export function GameSettings() {
icon={<Maximize01Icon />}
title="Force Fullscreen"
>
<Switch />
<Switch
defaultSelected={settings.force_fullscreen ?? false}
onChange={(val) => {
settings.force_fullscreen = val;
}}
/>
</SettingsRow>
<SettingsRow
@ -48,11 +74,19 @@ export function GameSettings() {
<div className="grid grid-cols-[70px_16px_70px] gap-2 grid-justify-center grid-items-center">
<TextField
className="text-center"
defaultValue={settings.res?.width.toString()}
onChange={(e) => {
settings.res!.width = Number(e.target.value);
}}
type="number"
/>
<XIcon className="size-4 self-center" />
<TextField
className="text-center"
defaultValue={settings.res?.height.toString()}
onChange={(e) => {
settings.res!.height = Number(e.target.value);
}}
type="number"
/>
</div>
@ -65,21 +99,12 @@ export function GameSettings() {
>
<div className="flex items-center gap-x-4 flex-justify-center">
<div className="flex flex-row items-center gap-x-2">
<span>Min:</span>
<TextField
className="text-center"
max={2024}
min={1}
type="number"
/>
</div>
<div className="flex flex-row items-center gap-x-2">
<span>Max:</span>
<TextField
className="text-center"
max={2024}
min={1}
defaultValue={settings.mem_max?.toString()}
onChange={(e) => {
settings.mem_max = Number(e.target.value);
}}
type="number"
/>
</div>

View file

@ -2,6 +2,7 @@ use interpulse::api::minecraft::Version;
use onelauncher_entity::{clusters, icon::Icon, loader::GameLoader, prelude::entity};
use sea_orm::ActiveValue::Set;
use serde::Serialize;
use crate::store::{Settings, State};
use tauri::{AppHandle, Runtime};
use crate::{api::{self, cluster::dao::ClusterId}, error::{LauncherError, LauncherResult}, store::{credentials::MinecraftCredentials, Core}};
@ -64,6 +65,13 @@ pub trait TauriLauncherApi {
#[taurpc(alias = "openMsaLogin")]
async fn open_msa_login<R: Runtime>(app_handle: AppHandle<R>) -> LauncherResult<Option<MinecraftCredentials>>;
// Settings
#[taurpc(alias = "readSettings")]
async fn read_settings() -> LauncherResult<Settings>;
#[taurpc(alias = "writeSettings")]
async fn write_settings(setting: Settings) -> LauncherResult<()>;
}
@ -262,4 +270,22 @@ impl TauriLauncherApi for TauriLauncherApiImpl {
Ok(None)
}
async fn read_settings(self) -> LauncherResult<Settings> {
let state = State::get().await?;
let settings = state.settings.read().await;
Ok(settings.clone())
}
async fn write_settings(self, setting: Settings) -> LauncherResult<()> {
let state = State::get().await?;
let mut settings = state.settings.write().await;
*settings = setting;
settings.save().await?;
Ok(())
}
}

View file

@ -36,8 +36,8 @@ const dropdownStyles = tv({
triggerCustomIcon: 'w-4 h-4',
popover: [
// width is not full instead it behaves like w-screen for some fucking reason
'mt-1 rounded-lg shadow-md',
// i'll fix this if i dont forget
'mt-1 w-full rounded-lg shadow-md',
'bg-component-bg border border-component-border',
'data-[entering]:animate-in data-[entering]:fade-in data-[entering]:zoom-in-95',
'data-[exiting]:animate-out data-[exiting]:fade-out data-[exiting]:zoom-out-95',