Files
feishin/src/renderer/features/titlebar/components/app-menu.tsx
T
2026-04-04 18:25:04 -07:00

422 lines
14 KiB
TypeScript

import { openModal } from '@mantine/modals';
import isElectron from 'is-electron';
import { Fragment, ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router';
import packageJson from '../../../../../package.json';
import styles from './app-menu.module.css';
import { isServerLock } from '/@/renderer/features/action-required/utils/window-properties';
import { ServerList } from '/@/renderer/features/servers/components/server-list';
import { openSettingsModal } from '/@/renderer/features/settings/utils/open-settings-modal';
import { ServerSelector } from '/@/renderer/features/sidebar/components/server-selector';
import { openReleaseNotesModal } from '/@/renderer/release-notes-modal';
import {
useAppStore,
useAppStoreActions,
useCommandPalette,
useCurrentServer,
useGeneralSettings,
useSettingsStoreActions,
} from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { DropdownMenu, MenuItemProps } from '/@/shared/components/dropdown-menu/dropdown-menu';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import { toast } from '/@/shared/components/toast/toast';
const browser = isElectron() ? window.api.browser : null;
interface BaseMenuItem {
id: string;
type: 'conditional-group' | 'conditional-item' | 'custom' | 'divider' | 'item';
}
interface ConditionalGroupItem extends BaseMenuItem {
condition: boolean;
items: MenuItem[];
type: 'conditional-group';
}
interface ConditionalItem extends BaseMenuItem {
condition: boolean;
item: Omit<MenuItem, 'id' | 'type'>;
type: 'conditional-item';
}
interface CustomItem extends BaseMenuItem {
component: ReactNode;
type: 'custom';
}
interface DividerItem extends BaseMenuItem {
type: 'divider';
}
type MenuItem = ConditionalGroupItem | ConditionalItem | CustomItem | DividerItem | RegularMenuItem;
interface RegularMenuItem extends BaseMenuItem {
component?: 'a' | typeof Link;
href?: string;
icon?: keyof typeof import('/@/shared/components/icon/icon').AppIcon;
iconColor?:
| 'contrast'
| 'default'
| 'error'
| 'info'
| 'inherit'
| 'muted'
| 'primary'
| 'success'
| 'warn';
label: string;
leftSection?: ReactNode;
onClick?: () => void;
rightSection?: ReactNode;
target?: string;
to?: string;
type: 'item';
}
export const AppMenu = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const collapsed = useAppStore((state) => state.sidebar.collapsed);
const privateMode = useAppStore((state) => state.privateMode);
const { setPrivateMode, setSideBar } = useAppStoreActions();
const { setSettings } = useSettingsStoreActions();
const settings = useGeneralSettings();
const currentServer = useCurrentServer();
const { open: openCommandPalette } = useCommandPalette();
const handleBrowserDevTools = () => {
browser?.devtools();
};
const handleCollapseSidebar = () => {
setSideBar({ collapsed: true });
};
const handleExpandSidebar = () => {
setSideBar({ collapsed: false });
};
const handlePrivateModeOff = () => {
setPrivateMode(false);
toast.info({
message: t('form.privateMode.disabled', { postProcess: 'sentenceCase' }),
title: t('form.privateMode.title', { postProcess: 'sentenceCase' }),
});
};
const handlePrivateModeOn = () => {
setPrivateMode(true);
toast.info({
message: t('form.privateMode.enabled', { postProcess: 'sentenceCase' }),
title: t('form.privateMode.title', { postProcess: 'sentenceCase' }),
});
};
const handleManageServersModal = () => {
openModal({
children: <ServerList />,
title: t('page.manageServers.title', { postProcess: 'titleCase' }),
});
};
const handleQuit = () => {
browser?.quit();
};
const handleSetSideQueueLayout = (sideQueueLayout: 'horizontal' | 'vertical') => {
setSettings({
general: {
...settings,
sideQueueLayout,
},
});
};
const serverHeaderMenuItems: MenuItem[] = currentServer
? [
{
component: (
<div className={styles.serverSelector}>
<ServerSelector />
</div>
),
id: 'server-selector',
type: 'custom',
},
{
id: 'divider-server',
type: 'divider',
},
]
: [];
const menuConfig: MenuItem[] = [
...serverHeaderMenuItems,
{
icon: 'search',
id: 'command-palette',
label: t('page.appMenu.commandPalette', { postProcess: 'sentenceCase' }),
onClick: openCommandPalette,
type: 'item',
},
{
id: 'divider-1',
type: 'divider',
},
{
condition: collapsed,
id: 'navigation-group',
items: [
{
icon: 'arrowLeftS',
id: 'go-back',
label: t('page.appMenu.goBack', { postProcess: 'sentenceCase' }),
onClick: () => navigate(-1),
type: 'item',
},
{
icon: 'arrowRightS',
id: 'go-forward',
label: t('page.appMenu.goForward', { postProcess: 'sentenceCase' }),
onClick: () => navigate(1),
type: 'item',
},
],
type: 'conditional-group',
},
{
condition: collapsed,
id: 'sidebar-expand',
item: {
icon: 'panelRightOpen',
id: 'expand-sidebar',
label: t('page.appMenu.expandSidebar', { postProcess: 'sentenceCase' }),
onClick: handleExpandSidebar,
type: 'item',
},
type: 'conditional-item',
},
{
condition: !collapsed,
id: 'sidebar-collapse',
item: {
icon: 'panelRightClose',
id: 'collapse-sidebar',
label: t('page.appMenu.collapseSidebar', { postProcess: 'sentenceCase' }),
onClick: handleCollapseSidebar,
type: 'item',
},
type: 'conditional-item',
},
{
id: 'divider-2',
type: 'divider',
},
{
condition: !isServerLock(),
id: 'manage-servers',
item: {
label: t('page.appMenu.manageServers', { postProcess: 'sentenceCase' }),
leftSection: <Icon icon="edit" />,
onClick: handleManageServersModal,
type: 'item',
},
type: 'conditional-item',
},
{
id: 'divider-3',
type: 'divider',
},
{
icon: 'settings',
id: 'settings',
label: t('page.appMenu.settings', { postProcess: 'sentenceCase' }),
onClick: () => openSettingsModal(),
type: 'item',
},
{
condition: privateMode,
id: 'private-mode-off',
item: {
icon: 'lock',
iconColor: 'error',
label: t('page.appMenu.privateModeOff', { postProcess: 'sentenceCase' }),
onClick: handlePrivateModeOff,
type: 'item',
},
type: 'conditional-item',
},
{
condition: !privateMode,
id: 'private-mode-on',
item: {
icon: 'lockOpen',
label: t('page.appMenu.privateModeOn', { postProcess: 'sentenceCase' }),
onClick: handlePrivateModeOn,
type: 'item',
},
type: 'conditional-item',
},
{
id: 'divider-4',
type: 'divider',
},
{
icon: 'brandGitHub',
id: 'version',
label: t('page.appMenu.version', {
postProcess: 'sentenceCase',
version: packageJson.version,
}),
onClick: () =>
openReleaseNotesModal(
t('common.newVersion', {
postProcess: 'sentenceCase',
version: packageJson.version,
}) as string,
),
type: 'item',
},
{
condition: isElectron(),
id: 'devtools',
item: {
icon: 'appWindow',
id: 'open-devtools',
label: t('page.appMenu.openBrowserDevtools', { postProcess: 'sentenceCase' }),
onClick: handleBrowserDevTools,
type: 'item',
},
type: 'conditional-item',
},
{
condition: isElectron(),
id: 'quit',
item: {
icon: 'x',
id: 'quit-app',
label: t('page.appMenu.quit', { postProcess: 'sentenceCase' }),
onClick: handleQuit,
type: 'item',
},
type: 'conditional-item',
},
{
id: 'divider-5',
type: 'divider',
},
{
condition: settings.sideQueueType === 'sideQueue',
id: 'layout-toggle-group',
items: [
{
component: (
<Group gap="xs" grow pb="xs" pt="sm" px="xs" w="100%">
<ActionIcon
icon="layoutPanelRight"
iconProps={{
size: 'xl',
}}
onClick={() => handleSetSideQueueLayout('horizontal')}
tooltip={{
label: t('setting.sidePlayQueueLayout', {
context: 'optionHorizontal',
postProcess: 'sentenceCase',
}),
openDelay: 0,
position: 'bottom',
}}
variant={
settings.sideQueueLayout === 'horizontal'
? 'default'
: 'transparent'
}
/>
<ActionIcon
icon="layoutPanelBottom"
iconProps={{
size: 'xl',
}}
onClick={() => handleSetSideQueueLayout('vertical')}
tooltip={{
label: t('setting.sidePlayQueueLayout', {
context: 'optionVertical',
postProcess: 'sentenceCase',
}),
openDelay: 0,
position: 'bottom',
}}
variant={
settings.sideQueueLayout === 'vertical'
? 'default'
: 'transparent'
}
/>
</Group>
),
id: 'layout-toggle',
type: 'custom',
},
],
type: 'conditional-group',
},
];
const renderMenuItem = (item: MenuItem): ReactNode => {
switch (item.type) {
case 'conditional-group':
if (!item.condition) return null;
return (
<div key={item.id}>
{item.items.map((subItem) => {
return <Fragment key={subItem.id}>{renderMenuItem(subItem)}</Fragment>;
})}
</div>
);
case 'conditional-item':
if (!item.condition) return null;
return <Fragment key={item.id}>{renderMenuItem(item.item as MenuItem)}</Fragment>;
case 'custom':
return <div key={item.id}>{item.component}</div>;
case 'divider':
return <DropdownMenu.Divider key={item.id} />;
case 'item': {
const leftSection =
item.leftSection ||
(item.icon && <Icon color={item.iconColor} icon={item.icon} />);
const props = {
leftSection,
...(item.rightSection && { rightSection: item.rightSection }),
...(item.onClick && { onClick: item.onClick }),
...(item.component && { component: item.component }),
...(item.to && { to: item.to }),
...(item.href && { href: item.href }),
...(item.target && { target: item.target }),
} as MenuItemProps;
return (
<DropdownMenu.Item key={item.id} {...props}>
{item.label}
</DropdownMenu.Item>
);
}
default:
return null;
}
};
return <>{menuConfig.map((item) => renderMenuItem(item))}</>;
};