feat: match notification style and re-enable notifications

This commit is contained in:
emirsassan 2025-07-11 15:43:56 +03:00
parent 65cceba2b3
commit ad45798ea2
7 changed files with 86 additions and 63 deletions

View file

@ -2,10 +2,9 @@ import type { ToOptions } from '@tanstack/react-router';
import useNotifications from '@/hooks/useNotification';
import { bindings } from '@/main';
import { useCommand } from '@onelauncher/common';
import { Button, Menu, Popup } from '@onelauncher/common/components';
import { Button, Popup } from '@onelauncher/common/components';
import { Link } from '@tanstack/react-router';
import { Bell01Icon, Cloud01Icon, TerminalBrowserIcon } from '@untitled-theme/icons-react';
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';
@ -49,6 +48,7 @@ function Navbar() {
</span>
)}
</Button>
<Popup>
<NotificationPopup />
</Popup>

View file

@ -1,7 +1,6 @@
import type { GameLoader, Version, VersionType } from '@/bindings.gen';
import type { JSX, RefAttributes } from 'react';
import DefaultBanner from '@/assets/images/default_banner.png';
import DefaultClusterBanner from '@/assets/images/default_instance_cover.jpg';
import LauncherIcon from '@/components/content/LauncherIcon';
import Modal from '@/components/overlay/Modal';
import ScrollableContainer from '@/components/ScrollableContainer';
@ -9,6 +8,7 @@ import { bindings } from '@/main';
import { LAUNCHER_IMPORT_TYPES, LOADERS, upperFirst } from '@/utils';
import { useCommand, useCommandMut } from '@onelauncher/common';
import { Button, Dropdown, SelectList, TextField } from '@onelauncher/common/components';
import { useQueryClient } from '@tanstack/react-query';
import { ArrowRightIcon, PlusIcon, SearchMdIcon, Server01Icon, TextInputIcon, User03Icon } from '@untitled-theme/icons-react';
import { useCallback, useEffect, useState } from 'react';
import { Checkbox } from 'react-aria-components';
@ -25,24 +25,37 @@ const STEPS = [
},
] as const;
interface ClusterFormData {
loader: GameLoader;
clusterName: string;
clusterVersion: string;
clusterProvider: string;
}
export function NewClusterCreate() {
const queryClient = useQueryClient();
const [currentStepIndex, setCurrentStepIndex] = useState(0);
const [formData, setFormData] = useState({
loader: '',
const [formData, setFormData] = useState<ClusterFormData>({
loader: 'vanilla',
clusterName: '',
clusterVersion: '',
clusterProvider: '',
});
const create = useCommandMut(() => bindings.core.createCluster({
icon: DefaultClusterBanner,
mc_loader: formData.loader as GameLoader,
icon: null,
mc_loader: formData.loader,
mc_loader_version: null,
mc_version: formData.clusterVersion,
name: formData.clusterName,
}));
}), {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['getClusters'] });
},
});
function handleCreate() {
if (!isLastInputStep)
if (!isLastInputStep && !formData.clusterName)
return;
create.mutate();
@ -70,10 +83,7 @@ export function NewClusterCreate() {
switch (currentStepConfig.id) {
case 'loader': {
stepContent = (
<ClusterLoader
onSelectLoader={loader => setFormData(prev => ({ ...prev, loader }))}
selectedLoader={formData.loader}
/>
<ClusterLoader onSelectProvider={provider => setFormData(prev => ({ ...prev, clusterProvider: provider }))} selectedProvider={formData.clusterProvider} />
);
break;
}
@ -81,6 +91,7 @@ export function NewClusterCreate() {
case 'info': {
stepContent = (
<ClusterInformation
clusterLoader={formData.loader}
clusterName={formData.clusterName}
clusterVersion={formData.clusterVersion}
onClusterLoaderChange={loader => setFormData(prev => ({ ...prev, loader }))}
@ -98,7 +109,7 @@ export function NewClusterCreate() {
const nextButtonText = isLastInputStep ? 'Create' : 'Next';
let isNextDisabled = false;
if (currentStepConfig.id === 'loader' && !formData.loader)
if (currentStepConfig.id === 'loader' && !formData.clusterProvider)
isNextDisabled = true;
else if (
currentStepConfig.id === 'info'
@ -167,19 +178,19 @@ export function NewClusterCreate() {
}
interface ClusterLoaderProps {
selectedLoader: string;
onSelectLoader: (loader: string) => void;
selectedProvider: string;
onSelectProvider: (provider: string) => void;
}
function ClusterLoader({ selectedLoader, onSelectLoader }: ClusterLoaderProps) {
function ClusterLoader({ selectedProvider, onSelectProvider }: ClusterLoaderProps) {
return (
<>
<div className="grid grid-cols-3 gap-2">
<ProviderCard
icon={<User03Icon />}
name="New"
selected={selectedLoader === 'new'}
setSelected={() => onSelectLoader('new')}
selected={selectedProvider === 'new'}
setSelected={() => onSelectProvider('new')}
/>
{LAUNCHER_IMPORT_TYPES.map((data, i) => (
@ -187,8 +198,8 @@ function ClusterLoader({ selectedLoader, onSelectLoader }: ClusterLoaderProps) {
icon={<LauncherIcon launcher={data} />}
key={i}
name={data}
selected={selectedLoader === data}
setSelected={() => onSelectLoader(data)}
selected={selectedProvider === data}
setSelected={() => onSelectProvider(data)}
/>
))}
</div>
@ -224,6 +235,7 @@ interface ClusterInformationProps {
onClusterNameChange: (name: string) => void;
clusterVersion: string;
onClusterVersionChange: (version: string) => void;
clusterLoader: GameLoader;
onClusterLoaderChange: (loader: GameLoader) => void;
}
@ -232,6 +244,7 @@ function ClusterInformation({
onClusterNameChange,
clusterVersion,
onClusterVersionChange,
clusterLoader,
onClusterLoaderChange,
}: ClusterInformationProps) {
const versions = useCommand('getGameVersions', bindings.core.getGameVersions);
@ -244,6 +257,7 @@ function ClusterInformation({
iconLeft={<TextInputIcon className="size-4" />}
onChange={e => onClusterNameChange(e.target.value)}
placeholder="Name"
required
value={clusterName}
/>
</Option>
@ -254,7 +268,7 @@ function ClusterInformation({
<Option header="Loader">
{/* TODO: fixme */}
<Dropdown defaultSelectedKey="vanilla" onSelectionChange={e => onClusterLoaderChange(e.toString() as GameLoader)}>
<Dropdown defaultSelectedKey="vanilla" onSelectionChange={e => onClusterLoaderChange(e.toString() as GameLoader)} selectedKey={clusterLoader}>
{LOADERS.map(loader => (
<Dropdown.Item id={loader} key={loader}>
<div className="flex flex-row gap-2">

View file

@ -1,7 +1,7 @@
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 { Button, Show } from '@onelauncher/common/components';
import { InfoCircleIcon, XIcon } from '@untitled-theme/icons-react';
import { useCallback, useEffect, useState } from 'react';
import { twMerge } from 'tailwind-merge';
@ -15,6 +15,8 @@ interface NotificationToastProps {
onRemove: (id: string) => void;
}
const fractionEnded = (fraction: number | undefined) => fraction !== undefined && fraction >= 0.97;
function NotificationToast({ notification, onRemove }: NotificationToastProps) {
const [isVisible, setIsVisible] = useState(false);
const [isRemoving, setIsRemoving] = useState(false);
@ -41,22 +43,13 @@ function NotificationToast({ notification, onRemove }: NotificationToastProps) {
}, [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 className={twMerge('flex flex-col gap-y-1 p-2 bg-component-bg border border-component-border rounded-lg shadow-lg', isVisible && !isRemoving ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0')}>
<div className="grid grid-cols-[24px_1fr_auto] min-h-10 place-items-center gap-3">
<InfoCircleIcon className="h-6 w-6 text-fg-primary" />
<div className="w-full flex flex-col gap-y-1">
<span className="text-fg-primary font-medium capitalize">{notification.title}</span>
<span className="text-sm text-fg-secondary/60 capitalize">{notification.message}</span>
</div>
<Button
className="flex-shrink-0 h-6 w-6 p-0"
@ -68,14 +61,16 @@ function NotificationToast({ notification, onRemove }: NotificationToastProps) {
</Button>
</div>
{notification.fraction !== undefined && (
<div className="w-full bg-component-bg-hover rounded-full h-2 mt-3">
<Show when={notification.fraction !== undefined && !fractionEnded(notification.fraction)}>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-brand-disabled/10">
<div
className="bg-brand h-2 rounded-full transition-all duration-300"
style={{ width: `${Math.max(0, Math.min(100, notification.fraction))}%` }}
className="h-full max-w-full min-w-0 rounded-full bg-brand transition-width"
style={{
width: `${Math.floor(notification.fraction! * 100)}%`,
}}
/>
</div>
)}
</Show>
</div>
);
}

View file

@ -1,8 +1,7 @@
import type { NotificationData } from '@/hooks/useNotification';
import useNotifications from '@/hooks/useNotification';
import { Button, Menu, Popup } from '@onelauncher/common/components';
import { Button, Popup } from '@onelauncher/common/components';
import { XIcon } from '@untitled-theme/icons-react';
import { Popover } from 'react-aria-components';
interface NotificationItemProps {
id: string;
@ -60,7 +59,7 @@ function NotificationPopup() {
);
return (
<Popup className="min-w-80 w-80 max-w-96 max-h-96 overflow-y-auto">
<div className="min-w-80 w-80 max-w-96 max-h-96 overflow-y-auto">
<div className="flex items-center justify-between border-b border-component-border">
<h3 className="font-semibold text-fg-primary">Notifications</h3>
{notificationEntries.length > 0 && (
@ -86,7 +85,7 @@ function NotificationPopup() {
</div>
</div>
))}
</Popup>
</div>
);
}

View file

@ -26,7 +26,7 @@ interface NotificationContextValue {
const defaultContextValue: NotificationContextValue = {
notifications: {},
setNotifications: () => {},
setNotifications: () => { },
};
const NotificationContext = createContext<NotificationContextValue>(defaultContextValue);
@ -42,13 +42,29 @@ export const NotificationProvider = memo(({ children }: NotificationProviderProp
let cleanup: (() => void) | undefined;
bindings.events.ingress.on((e) => {
setNotifications(prev => ({
...prev,
[e.id]: {
title: `${e.ingress_type}`,
message: e.message,
},
}));
if (typeof e.ingress_type === 'object') {
if ('PrepareCluster' in e.ingress_type) {
const { cluster_name } = e.ingress_type.PrepareCluster;
setNotifications(prev => ({
...prev,
[e.id]: {
title: `Preparing ${cluster_name}`,
message: e.message,
fraction: e.percent ?? undefined,
},
}));
}
}
else {
setNotifications(prev => ({
...prev,
[e.id]: {
title: `${e.ingress_type}`,
message: e.message,
fraction: e.percent ?? undefined,
},
}));
}
}).then((u) => {
cleanup = u;
}).catch(console.error);
@ -81,10 +97,10 @@ function useNotifications(): HookReturn {
console.warn('useNotifications: Using default context (likely due to HMR or missing provider)');
return {
list: {},
set: () => {},
create: () => {},
remove: () => {},
clear: () => {},
set: () => { },
create: () => { },
remove: () => { },
clear: () => { },
};
}
throw new Error('useNotifications must be used within a NotificationProvider');

View file

@ -12,7 +12,7 @@ import { createFileRoute } from '@tanstack/react-router';
import { convertFileSrc } from '@tauri-apps/api/core';
import { open } from '@tauri-apps/plugin-dialog';
import { Edit02Icon, FolderIcon, ImagePlusIcon, Share07Icon, Tool02Icon, Trash01Icon } from '@untitled-theme/icons-react';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { twMerge } from 'tailwind-merge';
import Sidebar from '../settings/route';

View file

@ -11,7 +11,6 @@ import { createFileRoute, Link } from '@tanstack/react-router';
import { convertFileSrc } from '@tauri-apps/api/core';
import { PlayIcon } from '@untitled-theme/icons-react';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
import { useEffect } from 'react';
export const Route = createFileRoute('/app/')({
component: RouteComponent,