mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-09-11 00:22:56 -05:00
ui: update menu bar
This commit is contained in:
@@ -214,7 +214,7 @@ const zh = {
|
||||
languageDesc: '选择界面语言',
|
||||
menuBar: '菜单栏',
|
||||
menuBarDesc: '是否在菜单栏中显示图标',
|
||||
webTitle: '网站标题',
|
||||
webTitle: '网页标题',
|
||||
webTitleDesc: '自定义网站页面标题'
|
||||
},
|
||||
device: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Divider, Modal, Segmented } from 'antd';
|
||||
import { Divider, Modal, Segmented, Tooltip } from 'antd';
|
||||
import clsx from 'clsx';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { DiscIcon, HardDriveIcon } from 'lucide-react';
|
||||
@@ -61,15 +61,17 @@ export const Image = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex h-[30px] w-[30px] cursor-pointer items-center justify-center rounded hover:bg-neutral-700',
|
||||
isMounted ? 'text-blue-500' : 'text-neutral-300 hover:text-white'
|
||||
)}
|
||||
onClick={() => toggleModal(true)}
|
||||
>
|
||||
<DiscIcon size={18} />
|
||||
</div>
|
||||
<Tooltip title={t('image.title')} placement="bottom" mouseEnterDelay={0.6}>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex h-[30px] w-[30px] cursor-pointer items-center justify-center rounded hover:bg-neutral-700',
|
||||
isMounted ? 'text-blue-500' : 'text-neutral-300 hover:text-white'
|
||||
)}
|
||||
onClick={() => toggleModal(true)}
|
||||
>
|
||||
<DiscIcon size={18} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<Modal open={isModalOpen} footer={null} onCancel={() => toggleModal(false)}>
|
||||
<div className="flex items-center space-x-1">
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Divider, Tooltip } from 'antd';
|
||||
import { Divider } from 'antd';
|
||||
import clsx from 'clsx';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { MenuIcon, XIcon } from 'lucide-react';
|
||||
import { GripVerticalIcon } from 'lucide-react';
|
||||
import Draggable, { DraggableData, DraggableEvent } from 'react-draggable';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { getMenuDisabledItems } from '@/lib/localstorage.ts';
|
||||
import { menuDisabledItemsAtom, submenuOpenCountAtom } from '@/jotai/settings.ts';
|
||||
@@ -14,6 +13,7 @@ import { Fullscreen } from './fullscreen';
|
||||
import { Image } from './image';
|
||||
import { Keyboard } from './keyboard';
|
||||
import { Mouse } from './mouse';
|
||||
import { Collapse, Expand } from './operations';
|
||||
import { Power } from './power';
|
||||
import { Screen } from './screen';
|
||||
import { Script } from './script';
|
||||
@@ -21,39 +21,29 @@ import { Settings } from './settings';
|
||||
import { Terminal } from './terminal';
|
||||
import { Wol } from './wol';
|
||||
|
||||
const HIDE_TIMEOUT = 8000;
|
||||
|
||||
export const Menu = () => {
|
||||
const { t } = useTranslation();
|
||||
const [menuDisabledItems, setMenuDisabledItems] = useAtom(menuDisabledItemsAtom);
|
||||
const submenuOpenCount = useAtomValue(submenuOpenCountAtom);
|
||||
|
||||
const [isMenuExpanded, setIsMenuExpanded] = useState(true);
|
||||
const [isMenuMoved, setIsMenuMoved] = useState(false);
|
||||
const [isMenuHovered, setIsMenuHovered] = useState(false);
|
||||
const [isMenuHidden, setIsMenuHidden] = useState(false);
|
||||
const [bounds, setBounds] = useState({ left: 0, right: 0, top: 0, bottom: 0 });
|
||||
const [menuBounds, setMenuBounds] = useState({ left: 0, right: 0, top: 0, bottom: 0 });
|
||||
|
||||
const nodeRef = useRef<HTMLDivElement | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// init menu
|
||||
useEffect(() => {
|
||||
const items = getMenuDisabledItems();
|
||||
setMenuDisabledItems(items);
|
||||
|
||||
// react-draggable bounds
|
||||
const handleResize = () => {
|
||||
if (!nodeRef.current) return;
|
||||
|
||||
const elementRect = nodeRef.current.getBoundingClientRect();
|
||||
const width = (window.innerWidth - elementRect.width) / 2;
|
||||
|
||||
setBounds({
|
||||
left: -width,
|
||||
top: -10,
|
||||
right: width,
|
||||
bottom: window.innerHeight - elementRect.height - 10
|
||||
});
|
||||
};
|
||||
|
||||
startCountdown();
|
||||
handleResize();
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
@@ -62,24 +52,47 @@ export const Menu = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// handle menu bounds
|
||||
useEffect(() => {
|
||||
if (submenuOpenCount === 0) {
|
||||
startCountdown();
|
||||
} else {
|
||||
stopCountdown();
|
||||
}
|
||||
}, [submenuOpenCount]);
|
||||
handleResize();
|
||||
}, [menuDisabledItems, isMenuExpanded]);
|
||||
|
||||
function startCountdown() {
|
||||
if (submenuOpenCount > 0) {
|
||||
// handle hide timer
|
||||
useEffect(() => {
|
||||
if (submenuOpenCount === 0 && !isMenuHovered) {
|
||||
startCountdown();
|
||||
return;
|
||||
}
|
||||
|
||||
stopCountdown();
|
||||
}, [submenuOpenCount, isMenuHovered]);
|
||||
|
||||
function handleResize() {
|
||||
if (!nodeRef.current) return;
|
||||
|
||||
const elementRect = nodeRef.current.getBoundingClientRect();
|
||||
const width = (window.innerWidth - elementRect.width) / 2;
|
||||
|
||||
setMenuBounds({
|
||||
left: -width,
|
||||
top: -10,
|
||||
right: width,
|
||||
bottom: window.innerHeight - elementRect.height - 10
|
||||
});
|
||||
}
|
||||
|
||||
function startCountdown() {
|
||||
if (submenuOpenCount > 0 || !isMenuExpanded || isMenuMoved) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
|
||||
timerRef.current = setTimeout(() => {
|
||||
setIsMenuHidden(true);
|
||||
}, 5000);
|
||||
}, HIDE_TIMEOUT);
|
||||
}
|
||||
|
||||
function stopCountdown() {
|
||||
@@ -89,68 +102,77 @@ export const Menu = () => {
|
||||
}
|
||||
}
|
||||
|
||||
function handleDraggableStop(_e: DraggableEvent, data: DraggableData) {
|
||||
if (data.x !== 0 || data.y !== 0) {
|
||||
setIsMenuMoved(true);
|
||||
function handleMoved(_e: DraggableEvent, data: DraggableData) {
|
||||
if (data.x === 0 && data.y === 0) return;
|
||||
if (isMenuMoved) return;
|
||||
|
||||
setIsMenuMoved(true);
|
||||
}
|
||||
|
||||
function handleHovered(hovered: boolean) {
|
||||
setIsMenuHovered(hovered);
|
||||
if (hovered) {
|
||||
setIsMenuHidden(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseEnter() {
|
||||
stopCountdown();
|
||||
setIsMenuHidden(false);
|
||||
function isEnabled(item: string) {
|
||||
return !menuDisabledItems.includes(item);
|
||||
}
|
||||
|
||||
return (
|
||||
<Draggable
|
||||
nodeRef={nodeRef}
|
||||
bounds={bounds}
|
||||
bounds={menuBounds}
|
||||
handle="strong"
|
||||
positionOffset={{ x: '-50%', y: '0%' }}
|
||||
onStop={handleDraggableStop}
|
||||
onStop={handleMoved}
|
||||
>
|
||||
<div
|
||||
ref={nodeRef}
|
||||
className="fixed left-1/2 top-[10px] z-[1000] -translate-x-1/2"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={startCountdown}
|
||||
onMouseEnter={() => handleHovered(true)}
|
||||
onMouseLeave={() => handleHovered(false)}
|
||||
onBlur={() => handleHovered(false)}
|
||||
>
|
||||
{/* Trigger area for auto-show when hidden */}
|
||||
{isMenuExpanded && (
|
||||
<div className="absolute -top-[10px] left-0 right-0 h-[60px] w-full bg-transparent" />
|
||||
<div className="absolute -top-[10px] left-0 right-0 h-[46px] w-full bg-transparent" />
|
||||
)}
|
||||
|
||||
{/* Menubar */}
|
||||
<div className="sticky top-[10px] flex w-full justify-center">
|
||||
<div
|
||||
className={clsx(
|
||||
'h-[36px] items-center rounded bg-neutral-800/80 transition-all duration-300',
|
||||
'h-[36px] items-center rounded bg-neutral-800/80 pl-1 pr-2 transition-all duration-300',
|
||||
isMenuExpanded ? 'flex' : 'hidden',
|
||||
isMenuExpanded && !isMenuMoved && isMenuHidden
|
||||
? '-translate-y-[110%] opacity-80'
|
||||
: 'translate-y-0 opacity-100'
|
||||
isMenuHidden ? '-translate-y-[110%] opacity-80' : 'translate-y-0 opacity-100'
|
||||
)}
|
||||
>
|
||||
<strong>
|
||||
<div className="hidden h-[30px] cursor-move select-none items-center px-3 sm:flex">
|
||||
<img src="/sipeed.ico" width={18} height={18} draggable={false} alt="sipeed" />
|
||||
<div className="flex h-[30px] cursor-move select-none items-center justify-center pl-1 text-neutral-500">
|
||||
<GripVerticalIcon size={18} />
|
||||
</div>
|
||||
</strong>
|
||||
<Divider type="vertical" />
|
||||
|
||||
<Screen />
|
||||
<Keyboard />
|
||||
<Mouse />
|
||||
<Divider type="vertical" />
|
||||
|
||||
{!menuDisabledItems.includes('image') && <Image />}
|
||||
{!menuDisabledItems.includes('download') && <DownloadImage />}
|
||||
{!menuDisabledItems.includes('script') && <Script />}
|
||||
{!menuDisabledItems.includes('terminal') && <Terminal />}
|
||||
{!menuDisabledItems.includes('wol') && <Wol />}
|
||||
{isEnabled('image') && <Image />}
|
||||
{isEnabled('download') && <DownloadImage />}
|
||||
{isEnabled('script') && <Script />}
|
||||
{isEnabled('terminal') && <Terminal />}
|
||||
{isEnabled('wol') && <Wol />}
|
||||
|
||||
{['image', 'script', 'terminal', 'wol', 'download'].some(
|
||||
(key) => !menuDisabledItems.includes(key)
|
||||
) && <Divider type="vertical" />}
|
||||
{['image', 'download', 'script', 'terminal', 'wol'].some(isEnabled) && (
|
||||
<Divider type="vertical" />
|
||||
)}
|
||||
|
||||
{!menuDisabledItems.includes('power') && (
|
||||
{isEnabled('power') && (
|
||||
<>
|
||||
<Power />
|
||||
<Divider type="vertical" />
|
||||
@@ -158,29 +180,13 @@ export const Menu = () => {
|
||||
)}
|
||||
|
||||
<Settings />
|
||||
<Fullscreen />
|
||||
|
||||
<Tooltip title={t('menu.collapse')} placement="bottom" mouseEnterDelay={0.6}>
|
||||
<div
|
||||
className="mr-1 flex h-[30px] w-[30px] cursor-pointer items-center justify-center rounded text-neutral-300 hover:bg-neutral-700/80 hover:text-white"
|
||||
onClick={() => setIsMenuExpanded((expanded) => !expanded)}
|
||||
>
|
||||
<XIcon size={20} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
{isEnabled('fullscreen') && <Fullscreen />}
|
||||
{isEnabled('collapse') && <Collapse toggleMenu={setIsMenuExpanded} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isMenuExpanded && (
|
||||
<Tooltip title={t('menu.expand')} placement="bottom" mouseEnterDelay={0.6}>
|
||||
<div
|
||||
className="flex h-[30px] w-[32px] cursor-pointer items-center justify-center rounded bg-neutral-800/50 text-white/50 hover:bg-neutral-700 hover:text-white"
|
||||
onClick={() => setIsMenuExpanded((expanded) => !expanded)}
|
||||
>
|
||||
<MenuIcon size={20} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Menubar expand button */}
|
||||
{!isMenuExpanded && <Expand toggleMenu={setIsMenuExpanded} />}
|
||||
</div>
|
||||
</Draggable>
|
||||
);
|
||||
|
||||
22
web/src/pages/desktop/menu/operations/collapse.tsx
Normal file
22
web/src/pages/desktop/menu/operations/collapse.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Tooltip } from 'antd';
|
||||
import { XIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface CollapseProps {
|
||||
toggleMenu(expanded: boolean): void;
|
||||
}
|
||||
|
||||
export const Collapse = ({ toggleMenu }: CollapseProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Tooltip title={t('menu.collapse')} placement="bottom" mouseEnterDelay={0.6}>
|
||||
<div
|
||||
className="flex size-[30px] cursor-pointer items-center justify-center rounded text-neutral-300 hover:bg-neutral-700/80 hover:text-white"
|
||||
onClick={() => toggleMenu(false)}
|
||||
>
|
||||
<XIcon size={18} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
27
web/src/pages/desktop/menu/operations/expand.tsx
Normal file
27
web/src/pages/desktop/menu/operations/expand.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Divider } from 'antd';
|
||||
import { ChevronRightIcon, GripVerticalIcon } from 'lucide-react';
|
||||
|
||||
interface ExpandProps {
|
||||
toggleMenu: (expanded: boolean) => void;
|
||||
}
|
||||
|
||||
export const Expand = ({ toggleMenu }: ExpandProps) => {
|
||||
return (
|
||||
<div className="flex items-center rounded-lg bg-neutral-800/50 p-1">
|
||||
<strong>
|
||||
<div className="flex size-[26px] cursor-move select-none items-center justify-center text-neutral-500">
|
||||
<GripVerticalIcon size={18} />
|
||||
</div>
|
||||
</strong>
|
||||
|
||||
<Divider type="vertical" style={{ margin: '0 4px' }} />
|
||||
|
||||
<div
|
||||
className="flex size-[26px] cursor-pointer items-center justify-center rounded text-neutral-500 hover:bg-neutral-800/60 hover:text-white"
|
||||
onClick={() => toggleMenu(true)}
|
||||
>
|
||||
<ChevronRightIcon size={18} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
2
web/src/pages/desktop/menu/operations/index.tsx
Normal file
2
web/src/pages/desktop/menu/operations/index.tsx
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Collapse } from './collapse.tsx';
|
||||
export { Expand } from './expand.tsx';
|
||||
@@ -4,9 +4,11 @@ import {
|
||||
DiscIcon,
|
||||
DownloadIcon,
|
||||
FileJsonIcon,
|
||||
MaximizeIcon,
|
||||
NetworkIcon,
|
||||
PowerIcon,
|
||||
TerminalSquareIcon
|
||||
TerminalSquareIcon,
|
||||
XIcon
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -19,12 +21,14 @@ export const MenuBar = () => {
|
||||
const [menuDisabledItems, setMenuDisabledItems] = useAtom(menuDisabledItemsAtom);
|
||||
|
||||
const items = [
|
||||
{ key: 'image', icon: <DiscIcon size={16} className="text-neutral-400" /> },
|
||||
{ key: 'download', icon: <DownloadIcon size={16} className="text-neutral-400" /> },
|
||||
{ key: 'script', icon: <FileJsonIcon size={16} className="text-neutral-400" /> },
|
||||
{ key: 'terminal', icon: <TerminalSquareIcon size={16} className="text-neutral-400" /> },
|
||||
{ key: 'wol', icon: <NetworkIcon size={16} className="text-neutral-400" /> },
|
||||
{ key: 'power', icon: <PowerIcon size={16} className="text-neutral-400" /> }
|
||||
{ key: 'image', icon: <DiscIcon size={16} /> },
|
||||
{ key: 'download', icon: <DownloadIcon size={16} /> },
|
||||
{ key: 'script', icon: <FileJsonIcon size={16} /> },
|
||||
{ key: 'terminal', icon: <TerminalSquareIcon size={16} /> },
|
||||
{ key: 'wol', icon: <NetworkIcon size={16} /> },
|
||||
{ key: 'power', icon: <PowerIcon size={16} /> },
|
||||
{ key: 'fullscreen', icon: <MaximizeIcon size={16} />, label: 'fullscreen.toggle' },
|
||||
{ key: 'collapse', icon: <XIcon size={16} />, label: 'menu.collapse' }
|
||||
];
|
||||
|
||||
function updateItems(key: string) {
|
||||
@@ -48,10 +52,13 @@ export const MenuBar = () => {
|
||||
<div className="mt-8 flex flex-col space-y-5">
|
||||
{items.map((item) => (
|
||||
<div key={item.key} className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center space-x-2 text-neutral-400">
|
||||
{item.icon}
|
||||
<span>{t(`${item.key}.title`)}</span>
|
||||
<span className="text-neutral-300">
|
||||
{item.label ? t(item.label) : t(`${item.key}.title`)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
value={!menuDisabledItems.includes(item.key)}
|
||||
onChange={() => updateItems(item.key)}
|
||||
|
||||
Reference in New Issue
Block a user