feat: toasts customizability (#497)

* fix: Toasts always using the info icon

* feat: Toast Position Customizability

* feat: Toast duration customizability

* fix: send toast button

wide lol

* feat: toast auto close customizability

* fix: SettingNumber being bad
This commit is contained in:
Jacob 2026-03-08 15:50:50 +08:00 • committed by GitHub
parent 73a55ee58d
commit 978732a8fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 281 additions and 280 deletions

View file

@ -248,7 +248,7 @@ gallery: string[] }
export type SettingProfileModel = { name: string; java_id: number | null; res: Resolution | null; force_fullscreen: boolean | null; mem_max: number | null; launch_args: string | null; launch_env: string | null; hook_pre: string | null; hook_wrapper: string | null; hook_post: string | null; os_extra: SettingsOsExtra | null }
export type Settings = { global_game_settings: SettingProfileModel; allow_parallel_running_clusters: boolean; enable_gamemode: boolean; discord_enabled: boolean; seen_onboarding: boolean; mod_list_use_grid: boolean; max_concurrent_requests: number; settings_version: number; native_window_frame: boolean; log_debug_info: boolean; show_tanstack_dev_tools: boolean }
export type Settings = { global_game_settings: SettingProfileModel; allow_parallel_running_clusters: boolean; enable_gamemode: boolean; discord_enabled: boolean; seen_onboarding: boolean; mod_list_use_grid: boolean; max_concurrent_requests: number; settings_version: number; native_window_frame: boolean; log_debug_info: boolean; show_tanstack_dev_tools: boolean; toast_position: ToastPosition; toast_duration: number; toast_auto_close: boolean }
export type SettingsOsExtra = { enable_gamemode: boolean | null }
@ -256,6 +256,8 @@ export type SkinVariant = "classic" | "slim"
export type Sort = "Relevance" | "Downloads" | "Newest" | "Updated"
export type ToastPosition = "top-right" | "top-left" | "bottom-right" | "bottom-left"
export type Update = { version: string }
/**
@ -317,7 +319,7 @@ export type VersionType =
*/
"old_beta"
const ARGS_MAP = { 'debug':'{"getFullDebugInfoParsed":[],"getLocale":[],"getType":[],"hasDevSettings":[],"isInDev":[],"getFamily":[],"getPackageVersion":[],"getOsVersion":[],"getGitCommitHash":[],"getPlatform":[],"getBuildTimestamp":[],"getOsDistro":[],"getArch":[],"getFullDebugInfo":[],"getFullDebugInfoParsedString":[],"openDevTools":[]}', 'folders':'{"fromCluster":["folder_name"],"openCluster":["folder_name"]}', 'events':'{"process":["event"],"message":["event"],"ingress":["event"]}', 'core':'{"searchPackages":["provider","query"],"getLinkedPackages":["cluster_id"],"getProcessLogTail":["id","max_lines"],"downloadExternalPackage":["package","cluster_id","force","skip_compatibility"],"uploadSkinBytes":["access_token","skin_data","image_format","skin_variant"],"updateClusterById":["id","request"],"getLogs":["id"],"getGameVersions":[],"refreshAccounts":[],"syncCluster":["cluster_id"],"removeCape":["access_token"],"getClusterById":["id"],"openMsaLogin":[],"refreshAccount":["uuid"],"convertUsernameUUID":["username_uuid"],"getRunningProcessesByClusterId":["cluster_id"],"setDiscordRPCMessage":["message"],"open":["input"],"getClusters":[],"getProfileOrDefault":["name"],"getLogByName":["id","name"],"getPackage":["provider","slug"],"getUsers":[],"createSettingsProfile":["name"],"removeCluster":["id"],"getPackageBody":["provider","body"],"killProcess":["pid"],"changeSkin":["access_token","skin_url","skin_variant"],"getGlobalProfile":[],"getUser":["uuid"],"removeUser":["uuid"],"getDefaultUser":["fallback"],"getMultiplePackages":["provider","slugs"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"changeCape":["access_token","cape_uuid"],"launchCluster":["id","uuid","search_for_java"],"createCluster":["options"],"updateClusterProfile":["name","profile"],"getWorlds":["id"],"getScreenshots":["id"],"getLoadersForVersion":["mc_version"],"getUsersFromAuthor":["provider","author"],"isClusterRunning":["cluster_id"],"setClusterStage":["id","stage"],"setDefaultUser":["uuid"],"getRunningProcesses":[],"writeSettings":["setting"],"readSettings":[],"removePackage":["cluster_id","package_hash"],"fetchMinecraftProfile":["uuid"],"fetchLoggedInProfile":["access_token"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"togglePackage":["cluster_id","package_hash"],"installModpack":["modpack","cluster_id"]}', 'oneclient':'{"getVersions":[],"extractBundleOverrides":["bundle_path","cluster_id"],"downloadPackageFromBundle":["package","cluster_id","bundle_name","skip_compatibility"],"getBundlesFor":["cluster_id"],"isBundleSyncing":[],"installUpdate":[],"cacheArt":["path"],"refreshArt":["path"],"checkForUpdate":[],"getClustersGroupedByMajor":[],"updateBundlePackages":["cluster_id"]}' }
const ARGS_MAP = { 'events':'{"ingress":["event"],"process":["event"],"message":["event"]}', 'folders':'{"openCluster":["folder_name"],"fromCluster":["folder_name"]}', 'debug':'{"getLocale":[],"getPlatform":[],"getOsVersion":[],"getOsDistro":[],"isInDev":[],"getArch":[],"getFullDebugInfo":[],"getFullDebugInfoParsedString":[],"getPackageVersion":[],"getBuildTimestamp":[],"getType":[],"getGitCommitHash":[],"openDevTools":[],"getFamily":[],"getFullDebugInfoParsed":[],"hasDevSettings":[]}', 'oneclient':'{"getBundlesFor":["cluster_id"],"isBundleSyncing":[],"extractBundleOverrides":["bundle_path","cluster_id"],"installUpdate":[],"getClustersGroupedByMajor":[],"updateBundlePackages":["cluster_id"],"cacheArt":["path"],"checkForUpdate":[],"downloadPackageFromBundle":["package","cluster_id","bundle_name","skip_compatibility"],"refreshArt":["path"],"getVersions":[]}', 'core':'{"removePackage":["cluster_id","package_hash"],"getLinkedPackages":["cluster_id"],"refreshAccount":["uuid"],"createSettingsProfile":["name"],"changeCape":["access_token","cape_uuid"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"isClusterRunning":["cluster_id"],"convertUsernameUUID":["username_uuid"],"setDiscordRPCMessage":["message"],"removeUser":["uuid"],"uploadSkinBytes":["access_token","skin_data","image_format","skin_variant"],"syncCluster":["cluster_id"],"getRunningProcessesByClusterId":["cluster_id"],"removeCluster":["id"],"open":["input"],"downloadExternalPackage":["package","cluster_id","force","skip_compatibility"],"getWorlds":["id"],"getScreenshots":["id"],"killProcess":["pid"],"getUsers":[],"removeCape":["access_token"],"getClusters":[],"getUsersFromAuthor":["provider","author"],"installModpack":["modpack","cluster_id"],"fetchMinecraftProfile":["uuid"],"getUser":["uuid"],"getPackageBody":["provider","body"],"getLoadersForVersion":["mc_version"],"fetchLoggedInProfile":["access_token"],"getLogByName":["id","name"],"changeSkin":["access_token","skin_url","skin_variant"],"writeSettings":["setting"],"getDefaultUser":["fallback"],"setDefaultUser":["uuid"],"getLogs":["id"],"updateClusterProfile":["name","profile"],"getClusterById":["id"],"launchCluster":["id","uuid","search_for_java"],"refreshAccounts":[],"getProcessLogTail":["id","max_lines"],"getGlobalProfile":[],"getRunningProcesses":[],"openMsaLogin":[],"createCluster":["options"],"getProfileOrDefault":["name"],"getGameVersions":[],"searchPackages":["provider","query"],"updateClusterById":["id","request"],"getPackage":["provider","slug"],"togglePackage":["cluster_id","package_hash"],"getMultiplePackages":["provider","slugs"],"setClusterStage":["id","stage"],"readSettings":[],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"]}' }
export type Router = { 'folders': { fromCluster: (folderName: string) => Promise<string>,
openCluster: (folderName: string) => Promise<null> },
'oneclient': { getClustersGroupedByMajor: () => Promise<Partial<{ [key in number]: ClusterModel[] }>>,
@ -347,9 +349,6 @@ getFullDebugInfo: () => Promise<DebugInfoData>,
getFullDebugInfoParsed: () => Promise<DebugInfoParsedLine[]>,
getFullDebugInfoParsedString: () => Promise<string>,
hasDevSettings: () => Promise<boolean> },
'events': { ingress: (event: IngressPayload) => Promise<void>,
message: (event: MessagePayload) => Promise<void>,
process: (event: ProcessPayload) => Promise<void> },
'core': { getClusters: () => Promise<ClusterModel[]>,
getClusterById: (id: number) => Promise<ClusterModel | null>,
removeCluster: (id: number) => Promise<null>,
@ -403,7 +402,10 @@ changeCape: (accessToken: string, capeUuid: string) => Promise<MojangFullPlayerP
removeCape: (accessToken: string) => Promise<MojangFullPlayerProfile>,
convertUsernameUUID: (usernameUuid: string) => Promise<MowojangProfile>,
setDiscordRPCMessage: (message: string) => Promise<null>,
open: (input: string) => Promise<null> } };
open: (input: string) => Promise<null> },
'events': { ingress: (event: IngressPayload) => Promise<void>,
message: (event: MessagePayload) => Promise<void>,
process: (event: ProcessPayload) => Promise<void> } };
export type { InferCommandOutput }

View file

@ -1,4 +1,4 @@
import { copyDebugInfo } from '@/hooks/useDebugInfo';
import { useCopyDebugInfo } from '@/hooks/useDebugInfo';
import { bindings } from '@/main';
import { useCommand } from '@onelauncher/common';
import { Button } from '@onelauncher/common/components';
@ -6,6 +6,7 @@ import { Button } from '@onelauncher/common/components';
export function MadeBy() {
const { data: version } = useCommand(['getPackageVersion'], () => bindings.debug.getPackageVersion());
const { data: isInDev } = useCommand(['isInDev'], () => bindings.debug.isInDev());
const copyDebugInfo = useCopyDebugInfo();
return (
<Button

View file

@ -0,0 +1,10 @@
import { TitleCase } from '@/utils/string';
import { Dropdown } from '@onelauncher/common/components';
export function SettingDropdown<T extends string>({ setting, options }: { setting: [T, (value: T) => void]; options: Array<T> }) {
return (
<Dropdown onSelectionChange={key => setting[1](key as T)} selectedKey={setting[0]}>
{options.map(option => <Dropdown.Item id={option} key={option}>{TitleCase(option)}</Dropdown.Item>)}
</Dropdown>
);
}

View file

@ -0,0 +1,48 @@
import { TextField } from '@onelauncher/common/components';
import { useState } from 'react';
type NumberSetting = [
number,
(value: number) => void,
];
export function SettingNumber({ setting, placeholder, min, max }: { setting: NumberSetting; placeholder?: string; min?: number; max?: number }) {
const [inputValue, setInputValue] = useState(String(setting[0]));
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
const value = event.currentTarget.value;
setInputValue(value);
const num = Number(value);
if (!Number.isNaN(num))
setting[1](num);
}
function handleBlur() {
let num = Number(inputValue);
if (Number.isNaN(num)) {
setInputValue(String(setting[0]));
return;
}
if (min !== undefined)
num = Math.max(min, num);
if (max !== undefined)
num = Math.min(max, num);
setInputValue(String(num));
setting[1](num);
}
return (
<TextField
max={max}
min={min}
onBlur={handleBlur}
onChange={handleChange}
placeholder={placeholder}
type="number"
value={inputValue}
/>
);
}

View file

@ -0,0 +1,16 @@
import { TextField } from '@onelauncher/common/components';
type StringSetting = [
string,
(value: string) => void,
];
export function SettingString({ setting, placeholder }: { setting: StringSetting; placeholder?: string }) {
return (
<TextField
onChange={event => setting[1](event.currentTarget.value)}
placeholder={placeholder}
value={setting[0]}
/>
);
}

View file

@ -5,7 +5,7 @@ type BooleanSetting = [
(value: boolean) => void,
];
export function SettingsSwitch({ setting }: { setting: BooleanSetting }) {
export function SettingSwitch({ setting }: { setting: BooleanSetting }) {
return (
<Switch
isSelected={setting[0]}

View file

@ -0,0 +1,5 @@
export * from './SettingDropdown';
export * from './SettingNumber';
export * from './SettingsRow';
export * from './SettingString';
export * from './SettingSwitch';

View file

@ -13,8 +13,7 @@ export * from './ManageSkinButton';
export * from './Markdown';
export * from './Navbar';
export * from './overlay';
export * from './SettingsRow';
export * from './SettingSwitch';
export * from './Settings';
export * from './SheetPage';
export * from './SkinViewer';
export * from './Spinner';

View file

@ -1,6 +1,6 @@
import type { DebugInfoArray } from '@/hooks/useDebugInfo';
import { Overlay } from '@/components';
import { copyDebugInfo, useDebugInfo } from '@/hooks/useDebugInfo';
import { useCopyDebugInfo, useDebugInfo } from '@/hooks/useDebugInfo';
import { Button } from '@onelauncher/common/components';
export function DebugInfo() {
@ -14,6 +14,7 @@ export function DebugInfo() {
}
export function RawDebugInfo({ debugInfo }: { debugInfo: DebugInfoArray }) {
const copyDebugInfo = useCopyDebugInfo();
return (
<>
<div>

View file

@ -1,5 +1,5 @@
import { Overlay, SettingsRow, SettingsSwitch } from '@/components';
import { copyDebugInfo } from '@/hooks/useDebugInfo';
import { Overlay, SettingsRow, SettingSwitch } from '@/components';
import { useCopyDebugInfo } from '@/hooks/useDebugInfo';
import { useSettings } from '@/hooks/useSettings';
import { bindings } from '@/main';
import { Button } from '@onelauncher/common/components';
@ -7,6 +7,7 @@ import { Link } from '@tanstack/react-router';
export function SuperSecretDevOptions() {
const { createSetting } = useSettings();
const copyDebugInfo = useCopyDebugInfo();
return (
<Overlay.Dialog>
@ -14,11 +15,11 @@ export function SuperSecretDevOptions() {
<div>
<SettingsRow description="Enable The Tanstack Dev Tools and shows debug page" title="Show Dev stuff">
<SettingsSwitch setting={createSetting('show_tanstack_dev_tools')} />
<SettingSwitch setting={createSetting('show_tanstack_dev_tools')} />
</SettingsRow>
<SettingsRow description="WARNING! This requires a restart to apply. Logs out debug info" title="Log Debug Info">
<SettingsSwitch setting={createSetting('log_debug_info')} />
<SettingSwitch setting={createSetting('log_debug_info')} />
</SettingsRow>
<div className="grid grid-cols-4 gap-3 justify-items-center">

View file

@ -4,7 +4,7 @@ import type { HTMLAttributes } from 'react';
import type { ToastContentProps, ToastOptions } from 'react-toastify';
import { Spinner } from '@/components';
import { bindings } from '@/main';
import { toast, toastUpdate } from '@/utils/toast';
import { toastUpdate, useToast } from '@/utils/toast';
import { Button } from '@onelauncher/common/components';
import { AlertCircleIcon, AlertTriangleIcon, CheckCircleIcon, InfoCircleIcon, XCloseIcon } from '@untitled-theme/icons-react';
import { useEffect, useRef } from 'react';
@ -48,7 +48,7 @@ export function Toast({
return (
<div className={container()}>
<ToastIcon isLoading={toastProps.isLoading} type={toastProps.type} />
<ToastIcon isLoading={toastProps.isLoading} type={data.type} />
<div className="flex flex-col">
<h4 className="text-lg font-medium">{data.title}</h4>
@ -66,7 +66,12 @@ export function Toast({
</div>
<div className="w-full h-1 absolute left-0 right-0 bottom-0">
<ToastProgressBar closeToast={closeToast} isPaused={isPaused} toastProps={toastProps} />
<ToastProgressBar
closeToast={closeToast}
data={data}
isPaused={isPaused}
toastProps={toastProps}
/>
</div>
</div>
);
@ -76,20 +81,29 @@ function ToastProgressBar({
isPaused,
toastProps,
closeToast,
data,
}: {
toastProps: ToastOptions;
isPaused: boolean;
closeToast: () => void;
data: ToastData;
}) {
let progress = toastProps.progress;
if (data.shouldAutoClose === false) {
isPaused = true;
progress = 0;
}
const attrs: HTMLAttributes<HTMLDivElement> = {};
if (typeof toastProps.progress === 'number') {
if (typeof progress === 'number') {
attrs.style = {
transition: 'width 0.2s ease-in-out',
width: `${toastProps.progress * 100}%`,
width: `${progress * 100}%`,
};
if (toastProps.progress >= 1)
if (progress >= 1)
attrs.onTransitionEnd = closeToast;
}
else {
@ -128,6 +142,7 @@ const ingressNames: Record<(IngressType & string) | (keyof UnionToIntersection<I
export function Toasts() {
const toastIngressMapRef = useRef(new Map<string, ToastId>());
const listenerRef = useRef(false);
const toast = useToast();
useEffect(() => {
if (listenerRef.current)
@ -180,7 +195,6 @@ export function Toasts() {
return (
<ToastContainer
newestOnTop
position="bottom-right"
transition={cssTransition({
enter: 'animate-fade animate-duration-75',
exit: 'animate-fade animate-duration-75 animate-reverse',
@ -194,7 +208,7 @@ function ToastIcon({
type,
}: {
isLoading: boolean | undefined;
type: string;
type: ToastData['type'];
}) {
if (isLoading)
return <Spinner size="extraSmall" />;

View file

@ -1,7 +1,7 @@
import type { DebugInfoParsedLine } from '@/bindings.gen';
import type { ShortcutEvent } from '@tauri-apps/plugin-global-shortcut';
import { bindings } from '@/main';
import { toast } from '@/utils/toast';
import { useToast } from '@/utils/toast';
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
import { register } from '@tauri-apps/plugin-global-shortcut';
import { useEffect, useState } from 'react';
@ -22,17 +22,22 @@ export function useDebugInfo(): DebugInfoArray {
return debugInfo;
}
export async function copyDebugInfo() {
const info = await bindings.debug.getFullDebugInfoParsedString();
writeText(info);
toast({
type: 'info',
title: 'Debug Info',
message: 'Debug info has been copied to clipboard',
});
export function useCopyDebugInfo() {
const toast = useToast();
return async function copyDebugInfo() {
const info = await bindings.debug.getFullDebugInfoParsedString();
writeText(info);
toast({
type: 'info',
title: 'Debug Info',
message: 'Debug info has been copied to clipboard',
});
};
}
export function useDebugKeybind() {
const copyDebugInfo = useCopyDebugInfo();
const handleKeybind = (event: ShortcutEvent) => {
if (event.state !== 'Pressed')
return;

View file

@ -1,5 +1,6 @@
import type { ToOptions } from '@tanstack/react-router';
import { bindings } from '@/main';
import { ReplaceVariables } from '@/utils/string';
import { useCommand } from '@onelauncher/common';
import { useLocation } from '@tanstack/react-router';
import { useEffect } from 'react';
@ -39,11 +40,6 @@ export const ResolvedPathNames: Record<URLPath, string> = {
'/onboarding/language': 'Preparing OneClient',
};
// Credit - https://github.com/DuckySoLucky/hypixel-discord-chat-bridge/blob/d3ea84a26ebf094c8191d50b4954549e2dd4dc7f/src/contracts/helperFunctions.js#L216-L225
function ReplaceVariables(template: string, variables: Record<string, any>) {
return template.replace(/\{(\w+)\}/g, (match: any, name: string | number) => variables[name] ?? match);
}
export function useDiscordRPC() {
const location = useLocation();
const clusterId = location.search.clusterId ?? 0;

View file

@ -1,5 +1,5 @@
import { bindings } from '@/main';
import { toast } from '@/utils/toast';
import { useToast } from '@/utils/toast';
import { getMessageFromError, isLauncherError } from '@onelauncher/common';
import { useNavigate } from '@tanstack/react-router';
import { useCallback, useState } from 'react';
@ -8,6 +8,7 @@ export function useLaunchCluster(): { launch: (clusterId: number | undefined | n
export function useLaunchCluster(clusterId: number | undefined | null): { launch: () => void; showDownloadWarning: boolean; setShowDownloadWarning: (show: boolean) => void; forceLaunch: () => void };
export function useLaunchCluster(clusterId?: number | undefined | null) {
const toast = useToast();
const navigate = useNavigate();
const [showDownloadWarning, setShowDownloadWarning] = useState(false);
const [lastAttemptedId, setLastAttemptedId] = useState<number | null>(null);

View file

@ -2,7 +2,7 @@ import type { PlayerAnimation } from 'skinview3d';
import { ImportSkinModal, Overlay, RemoveSkinCapeModal, SheetPage, SkinViewer } from '@/components';
import { bindings } from '@/main';
import { getSkinUrl } from '@/utils/minecraft';
import { toast } from '@/utils/toast';
import { useToast } from '@/utils/toast';
import { Button } from '@onelauncher/common/components';
import { createFileRoute } from '@tanstack/react-router';
import { dataDir, downloadDir, join } from '@tauri-apps/api/path';
@ -111,6 +111,7 @@ export function MissingAccountData({ validSearch }: { validSearch: boolean }) {
function RouteComponent() {
const { profileData, profile, queryClient, validSearch, playerData } = Route.useRouteContext();
const toast = useToast();
const [capes, setCapes] = useState<Array<Cape>>([]);
const [selectedCape, setSelectedCape] = useState<string>('');

View file

@ -1,9 +1,10 @@
import type { ToastData } from '@/utils/toast';
import { MinecraftAuthErrorModal, minecraftAuthErrors, Overlay, RawDebugInfo, SettingsRow, SettingsSwitch, SheetPage } from '@/components';
import type { ToastData, ToastOptions } from '@/utils/toast';
import { MinecraftAuthErrorModal, minecraftAuthErrors, Overlay, RawDebugInfo, SettingNumber, SettingsRow, SettingSwitch, SheetPage } from '@/components';
import { useDebugInfo } from '@/hooks/useDebugInfo';
import { useSettings } from '@/hooks/useSettings';
import { bindings } from '@/main';
import { toast, ToastTypes } from '@/utils/toast';
import { TitleCase } from '@/utils/string';
import { ToastPositions, ToastTypes, useToast } from '@/utils/toast';
import { Button, Dropdown, TextField } from '@onelauncher/common/components';
import { createFileRoute } from '@tanstack/react-router';
import { dataDir, join } from '@tauri-apps/api/path';
@ -81,26 +82,22 @@ function AuthError() {
);
}
// Credit - https://github.com/DuckySoLucky/hypixel-discord-chat-bridge/blob/52887f12fce3bc9ebe91befaf394f289abd234a1/src/contracts/helperFunctions.js#L260-L278
function TitleCase(str: string): string {
if (!str)
return '';
if (typeof str !== 'string')
return '';
return str
.toLowerCase()
.replaceAll('_', ' ')
.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
function Toasts() {
const [title, setTitle] = useState<string>('Title');
const [message, setMessage] = useState<string | undefined>('Message');
const [type, setType] = useState<ToastData['type']>('info');
const [position, setPosition] = useState<ToastOptions['position'] | 'undefined'>('undefined');
const [duration, setDuration] = useState<number>(5000);
const [autoClose, setAutoClose] = useState<boolean>(true);
const toast = useToast();
const sendToast = () => toast({
type,
title,
message,
position: position === 'undefined' ? undefined : position,
autoClose: autoClose ? duration : false,
});
return (
<div className="flex flex-col gap-4">
@ -113,16 +110,30 @@ function Toasts() {
<p>Message:</p>
<TextField className="flex-1" onChange={e => setMessage(e.target.value)} value={message} />
</div>
<div className="flex flex-row gap-4 items-center">
<p>Toast Type:</p>
<p>Type:</p>
<Dropdown onSelectionChange={key => setType(key as ToastData['type'])} selectedKey={type}>
{ToastTypes.map(type => <Dropdown.Item id={type} key={type}>{TitleCase(type)}</Dropdown.Item>)}
</Dropdown>
</div>
<div className="flex flex-row gap-4">
<Button onPress={() => toast({ type, title, message })} size="normal">Send Toast</Button>
<div className="flex flex-row gap-4 items-center">
<p>Position:</p>
<Dropdown onSelectionChange={key => setPosition(key as ToastOptions['position'])} selectedKey={position}>
<Dropdown.Item id="undefined" key="undefined">Config</Dropdown.Item>
{ToastPositions.map(type => <Dropdown.Item id={type} key={type}>{TitleCase(type)}</Dropdown.Item>)}
</Dropdown>
</div>
<div className="flex flex-row gap-4 items-center">
<p>Duration:</p>
<SettingNumber max={60000} min={500} setting={[duration, (value: number) => setDuration(value)]} />
</div>
<div className="flex flex-row gap-4 items-center">
<p>Auto Close:</p>
<SettingSwitch setting={[autoClose, (value: boolean) => setAutoClose(value)]} />
</div>
<Button onPress={sendToast} size="normal">Send Toast</Button>
</div>
);
}
@ -136,19 +147,19 @@ function Settings() {
<div className="flex flex-row gap-4 flex-wrap">
<SettingsRow description="WARNING! This requires a restart to apply. Logs out debug info" title="Log Debug Info">
<SettingsSwitch setting={createSetting('log_debug_info')} />
<SettingSwitch setting={createSetting('log_debug_info')} />
</SettingsRow>
<SettingsRow description="Enable The Tanstack Dev Tools and shows debug page" title="Show Dev stuff">
<SettingsSwitch setting={createSetting('show_tanstack_dev_tools')} />
<SettingSwitch setting={createSetting('show_tanstack_dev_tools')} />
</SettingsRow>
<SettingsRow description="Seen onboarding" title="Seen Onboarding">
<SettingsSwitch setting={createSetting('seen_onboarding')} />
<SettingSwitch setting={createSetting('seen_onboarding')} />
</SettingsRow>
<SettingsRow description="Use Grid On Mods List" title="Use Grid On Mods List">
<SettingsSwitch setting={createSetting('mod_list_use_grid')} />
<SettingSwitch setting={createSetting('mod_list_use_grid')} />
</SettingsRow>
</div>
</div>

View file

@ -1,9 +1,9 @@
import { SettingsRow, SettingsSwitch } from '@/components';
import { SettingDropdown, SettingNumber, SettingsRow, SettingSwitch } from '@/components';
import { useSettings } from '@/hooks/useSettings';
import { Sidebar } from '@/routes/app/settings/route';
import { THEMES } from '@/utils/theming';
import { ToastPositions, useToast } from '@/utils/toast';
import { Button } from '@onelauncher/common/components';
import { createFileRoute } from '@tanstack/react-router';
import { Monitor01Icon, PackageIcon, Speedometer04Icon } from '@untitled-theme/icons-react';
export const Route = createFileRoute('/app/settings/appearance')({
component: RouteComponent,
@ -11,210 +11,34 @@ export const Route = createFileRoute('/app/settings/appearance')({
function RouteComponent() {
const { createSetting } = useSettings();
const toast = useToast();
const previewToast = () => toast({ type: 'info', title: 'Preview Toast', message: 'This is an preview of what a toast would look like' });
return (
<Sidebar.Page>
{/* <ScrollableContainer> */}
<div className="h-full">
<h1 className="text-xl">Appearance</h1>
<div className="flex flex-row items-center gap-4">
<PrimaryThemeCard />
<div className="grid grid-cols-3 h-full gap-4">
{/* <For each={Object.keys(THEMES) as (keyof typeof THEMES)[]}>
{theme => (
<For each={THEMES[theme ?? 'OneLauncher'].variants}>
{variant => (
<ThemeCard
setTheme={setTheme}
themeSelector={`${theme}-${variant.name}`}
/>
)}
</For>
)}
</For> */}
<SettingsRow.Header>Toasts</SettingsRow.Header>
{Object.keys(THEMES).map((theme) => {
return THEMES[theme as keyof typeof THEMES].variants.map((variant) => {
return (
<ThemeCard key={`${theme}-${variant.name}`} setTheme={() => { }} themeSelector={`${theme}-${variant.name}`} />
);
});
})}
</div>
</div>
<SettingsRow
description="Change the look of the package list."
icon={<PackageIcon />}
title="Package List Style"
>
<p>i hope i'll fix this</p>
<SettingsRow description="Where the toast will show up" title="Position">
<SettingDropdown options={ToastPositions} setting={createSetting('toast_position')} />
</SettingsRow>
<SettingsRow
description="Uses custom window frame for the launcher."
icon={<Monitor01Icon />}
title="Custom Window Frame"
>
<SettingsSwitch setting={createSetting('native_window_frame')} />
<SettingsRow description="Should the toast expire and auto close" title="Auto Close">
<SettingSwitch setting={createSetting('toast_auto_close')} />
</SettingsRow>
<SettingsRow
description="Disables all animations in the launcher."
icon={<Speedometer04Icon />}
title="Disable Animations"
>
{/* <SettingsSwitch setting={createSetting("disable_animations")}/> */}
<SettingsRow description="How long the toast should stay. In milliseconds" title="Duration">
<SettingNumber max={60000} min={500} setting={createSetting('toast_duration')} />
</SettingsRow>
<SettingsRow description="Preview a Toast" title="Preview Toast">
<Button onClick={previewToast} size="normal">Preview</Button>
</SettingsRow>
</div>
{/* </ScrollableContainer> */}
</Sidebar.Page>
);
}
interface ThemeCardProps {
themeSelector: string;
setTheme: (merged: string) => void;
};
function ThemeCard({
themeSelector,
setTheme,
}: ThemeCardProps) {
return (
<div className={`h-min theme-${themeSelector}`} onClick={() => setTheme(themeSelector)}>
<svg
fill="none"
height="78"
viewBox="0 0 126 78"
width="126"
xmlns="http://www.w3.org/2000/svg"
>
<rect
className="fill-page-elevated"
height="78"
rx="8"
width="126"
/>
<rect
className="stroke-border"
height="77"
rx="7.5"
stroke-opacity="0.1"
width="125"
x="0.5"
y="0.5"
/>
<path
className="stroke-fg-primary"
d="M8 16H116"
stroke-linecap="round"
stroke-width="5"
/>
<path
className="stroke-fg-secondary"
d="M8 26H56"
stroke-linecap="round"
stroke-width="3"
/>
<rect
className="fill-brand"
fill-opacity="0.5"
height="16"
rx="4"
width="64"
x="54"
y="54"
/>
<path d="M54 58C54 55.7909 55.7909 54 58 54H86V70H58C55.7909 70 54 68.2091 54 66V58Z" fill="rgb(var(--clr-brand))" />
</svg>
</div>
);
}
function PrimaryThemeCard() {
return (
<div className="w-[296] h-[183]">
<svg
fill="none"
height="183"
viewBox="0 0 296 183"
width="296"
xmlns="http://www.w3.org/2000/svg"
>
<rect
className="fill-page"
height="183"
rx="16"
width="296"
/>
<rect
className="stroke-border/10"
height="180"
rx="14.5"
strokeWidth="1.5"
width="293"
x="1.5"
y="1.5"
/>
<rect
className="fill-page-elevated"
height="123"
rx="8"
width="168"
x="112"
y="44"
/>
<rect
className="stroke-border/5"
height="122"
rx="7.5"
width="167"
x="112.5"
y="44.5"
/>
<rect
className="fill-brand/50"
height="16"
rx="4"
width="64"
x="208"
y="143"
/>
<path
className="stroke-fg-primary"
d="M132 64H240"
strokeLinecap="round"
strokeWidth="5"
/>
<path
className="stroke-fg-secondary"
d="M132 74H180"
strokeLinecap="round"
strokeWidth="3"
/>
<path
className="stroke-code-error"
d="M132 94H216"
strokeLinecap="round"
strokeWidth="4"
/>
<path
className="stroke-code-warn"
d="M132 104H192"
strokeLinecap="round"
strokeWidth="4"
/>
<path
className="stroke-code-info"
d="M132 114H210"
strokeLinecap="round"
strokeWidth="4"
/>
<path className="fill-brand" d="M208 147C208 144.791 209.791 143 212 143H240V159H212C209.791 159 208 157.209 208 155V147Z" />
</svg>
</div>
);
}

View file

@ -1,4 +1,4 @@
import { DebugInfo, Overlay, SettingsRow, SettingsSwitch } from '@/components';
import { DebugInfo, Overlay, SettingsRow, SettingSwitch } from '@/components';
import { useSettings } from '@/hooks/useSettings';
import { Sidebar } from '@/routes/app/settings/route';
import { Button } from '@onelauncher/common/components';
@ -32,11 +32,11 @@ function RouteComponent() {
<SettingsRow.Header>Dev Tools</SettingsRow.Header>
<SettingsRow description="WARNING! This requires a restart to apply. Logs out debug info" title="Log Debug Info">
<SettingsSwitch setting={createSetting('log_debug_info')} />
<SettingSwitch setting={createSetting('log_debug_info')} />
</SettingsRow>
<SettingsRow description="Enable The Tanstack Dev Tools and shows debug page" title="Show Dev stuff">
<SettingsSwitch setting={createSetting('show_tanstack_dev_tools')} />
<SettingSwitch setting={createSetting('show_tanstack_dev_tools')} />
</SettingsRow>
</div>

View file

@ -1,4 +1,4 @@
import { SettingsRow, SettingsSwitch } from '@/components';
import { SettingsRow, SettingSwitch } from '@/components';
import { useSettings } from '@/hooks/useSettings';
import { bindings } from '@/main';
import { Sidebar } from '@/routes/app/settings/route';
@ -34,7 +34,7 @@ function RouteComponent() {
icon={<Link03Icon />}
title="Discord RPC"
>
<SettingsSwitch setting={createSetting('discord_enabled')} />
<SettingSwitch setting={createSetting('discord_enabled')} />
</SettingsRow>
{/* <SettingsRow

View file

@ -1,7 +1,7 @@
import type { JSX } from 'react';
import { MadeBy, SheetPage } from '@/components';
import { createFileRoute, Outlet, useNavigate, useRouterState } from '@tanstack/react-router';
import { CodeSnippet02Icon, RefreshCcw02Icon, Rocket02Icon, Sliders04Icon } from '@untitled-theme/icons-react';
import { Brush01Icon, CodeSnippet02Icon, RefreshCcw02Icon, Rocket02Icon, Sliders04Icon } from '@untitled-theme/icons-react';
import { useEffect } from 'react';
export const Route = createFileRoute('/app/settings')({
@ -35,7 +35,7 @@ function RouteComponent() {
links={{
'Launcher Settings': [
[<Rocket02Icon key="general" />, 'General', '/'],
// [<Brush01Icon key="appearence" />, 'Appearance', '/appearance'],
[<Brush01Icon key="appearence" />, 'Appearance', '/appearance'],
// [<Key01Icon />, 'APIs', '/apis'],
// [<Globe01Icon />, 'Language', '/language'],
],

View file

@ -0,0 +1,21 @@
// Credit - https://github.com/DuckySoLucky/hypixel-discord-chat-bridge/blob/d3ea84a26ebf094c8191d50b4954549e2dd4dc7f/src/contracts/helperFunctions.js#L216-L225
export function ReplaceVariables(template: string, variables: Record<string, any>) {
return template.replace(/\{(\w+)\}/g, (match: any, name: string | number) => variables[name] ?? match);
}
// Credit - https://github.com/DuckySoLucky/hypixel-discord-chat-bridge/blob/52887f12fce3bc9ebe91befaf394f289abd234a1/src/contracts/helperFunctions.js#L260-L278
export function TitleCase(str: string): string {
if (!str)
return '';
if (typeof str !== 'string')
return '';
return str
.toLowerCase()
.replaceAll('_', ' ')
.replaceAll('-', ' ')
.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}

View file

@ -1,5 +1,7 @@
import type { ToastPosition } from '@/bindings.gen';
import type { UpdateOptions } from 'react-toastify';
import { Toast } from '@/components/overlay';
import { useSettings } from '@/hooks/useSettings';
import { toast as toastify } from 'react-toastify';
export type ToastId = string | number;
@ -8,6 +10,7 @@ export interface ToastData {
type: 'info' | 'success' | 'warning' | 'error';
title: string;
message?: string | undefined;
shouldAutoClose?: boolean | undefined;
}
export const ToastTypes: Array<ToastData['type']> = ['info', 'success', 'warning', 'error'];
@ -15,24 +18,43 @@ export interface ToastOptions extends ToastData {
progress?: number;
isLoading?: boolean | undefined;
autoClose?: number | false | undefined;
position?: ToastPosition;
}
export const ToastPositions: Array<NonNullable<ToastOptions['position']>> = ['top-right', 'top-left', 'bottom-right', 'bottom-left'];
export function toast({
progress,
isLoading,
autoClose = 5000,
...data
}: ToastOptions): ToastId {
return toastify(Toast, {
data,
export function useToast() {
const { setting } = useSettings();
return function toast({
progress,
isLoading,
autoClose,
closeOnClick: false,
closeButton: false,
hideProgressBar: true,
icon: false,
});
position,
...data
}: ToastOptions): ToastId {
if (position === undefined)
position = setting('toast_position');
if (autoClose === undefined)
autoClose = setting('toast_duration');
if (autoClose === false)
data.shouldAutoClose = false;
else
data.shouldAutoClose = setting('toast_auto_close');
return toastify(Toast, {
data,
progress,
isLoading,
autoClose,
closeOnClick: false,
closeButton: false,
hideProgressBar: true,
icon: false,
position,
});
};
}
export function toastUpdate(

View file

@ -1,11 +1,24 @@
use onelauncher_entity::setting_profiles;
use serde::{Deserialize, Serialize};
use specta::Type;
use crate::utils::io;
use crate::{LauncherResult, send_warning};
use super::Dirs;
#[derive(Debug, Serialize, Deserialize, Clone, Type)]
pub enum ToastPosition {
#[serde(rename = "top-right")]
TopRight,
#[serde(rename = "top-left")]
TopLeft,
#[serde(rename = "bottom-right")]
BottomRight,
#[serde(rename = "bottom-left")]
BottomLeft,
}
#[onelauncher_macro::specta]
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(default)]
@ -23,6 +36,11 @@ pub struct Settings {
#[cfg(feature = "tauri")]
pub log_debug_info: bool,
pub show_tanstack_dev_tools: bool,
// Toasts
pub toast_position: ToastPosition,
pub toast_duration: u32,
pub toast_auto_close: bool,
}
impl Default for Settings {
@ -41,6 +59,11 @@ impl Default for Settings {
#[cfg(feature = "tauri")]
log_debug_info: tauri::is_dev(),
show_tanstack_dev_tools: tauri::is_dev(),
// Toasts
toast_position: ToastPosition::BottomRight,
toast_duration: 5000,
toast_auto_close: true,
}
}
}