feat(web): add spaces, grouped channels and file chat

This commit is contained in:
justme committed 2026-10-13 01:39:23 +08:00
1 parent c7d8ef15bc
commit f6cfb12716
111 files changed
+4371 -506

No files matched your search

+40
View File
@@ -0,0 +1,40 @@
.menu {
position: fixed;
z-index: 80;
width: 210px;
max-width: calc(100vw - 16px);
max-height: calc(100dvh - 16px);
overflow: auto;
padding: var(--space-1);
border: 1px solid var(--line-strong);
border-radius: var(--radius-md);
background: var(--surface);
box-shadow: var(--shadow-pop);
}
.menu button {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
min-height: 36px;
padding: var(--space-2);
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
font: inherit;
font-size: 13px;
text-align: left;
cursor: pointer;
}
.menu button:hover,
.menu button:focus-visible {
background: var(--accent-soft);
}
.menu button:disabled {
color: var(--text-dim);
cursor: default;
}
+36
View File
@@ -0,0 +1,36 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { fitFloatingPoint } from '@/lib/explorer';
import { Icon, type IconName } from './Icon';
import styles from './ContextMenu.module.css';
export interface MenuAction { label: string; icon?: IconName; disabled?: boolean; action: () => void }
export function ContextMenu({ point, actions, onClose }: { point: { x: number; y: number }; actions: MenuAction[]; onClose: () => void }) {
const ref = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState(point);
useLayoutEffect(() => {
const node = ref.current; if (!node) return;
const previous = document.activeElement as HTMLElement | null;
setPosition(fitFloatingPoint(point.x, point.y, node.offsetWidth, node.offsetHeight, innerWidth, innerHeight));
node.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus();
return () => { if (previous?.isConnected && (document.activeElement === document.body || node.contains(document.activeElement))) previous.focus(); };
}, [point.x, point.y]);
useEffect(() => {
const outside = (event: PointerEvent) => { if (!ref.current?.contains(event.target as Node)) onClose(); };
const close = (event: KeyboardEvent) => { if (event.key === 'Escape') { event.preventDefault(); onClose(); } };
document.addEventListener('pointerdown', outside); document.addEventListener('keydown', close);
window.addEventListener('resize', onClose); window.addEventListener('blur', onClose);
return () => { document.removeEventListener('pointerdown', outside); document.removeEventListener('keydown', close);
window.removeEventListener('resize', onClose); window.removeEventListener('blur', onClose); };
}, [onClose]);
return createPortal(<div ref={ref} className={styles.menu} role="menu" aria-label="文件操作菜单" style={{ left: position.x, top: position.y }} onContextMenu={(event) => event.preventDefault()}
onKeyDown={(event) => {
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
event.preventDefault(); const buttons = [...(ref.current?.querySelectorAll<HTMLButtonElement>('button:not(:disabled)') ?? [])];
const current = buttons.indexOf(document.activeElement as HTMLButtonElement);
const index = event.key === 'Home' ? 0 : event.key === 'End' ? buttons.length - 1 : (current + (event.key === 'ArrowDown' ? 1 : -1) + buttons.length) % buttons.length;
buttons[index]?.focus();
}}>{actions.map((item) => <button type="button" key={item.label} role="menuitem" disabled={item.disabled} onClick={() => { onClose(); item.action(); }}>
{item.icon ? <Icon name={item.icon} size={16} /> : null}<span>{item.label}</span>
</button>)}</div>, document.body);
}
+3
View File
@@ -1,4 +1,5 @@
const PATHS = {
folder: 'M3 7V5h6l2 2h10v13H3z',
lock: 'M8 11V8a4 4 0 018 0v3 M6 11h12v9H6z',
share: 'M12 4v11 M8 8l4-4 4 4 M5 14v6h14v-6',
copy: 'M9 9h10v11H9z M5 15V4h10',
@@ -30,9 +31,11 @@ const PATHS = {
paste: 'M9 4h6v3H9z M7 5H5v16h14V5h-2',
history: 'M4 12a8 8 0 108-8 M4 4v5h5 M12 8v4l3 2',
chevronRight: 'M9 6l6 6-6 6',
chevronDown: 'M6 9l6 6 6-6',
chevronLeft: 'M15 6l-6 6 6 6',
panelRight: 'M4 5h16v14H4z M15 5v14',
bell: 'M6 16V11a6 6 0 0112 0v5l2 2H4z M10 20h4',
settings: 'M12 8a4 4 0 100 8 4 4 0 000-8z M19.4 15a1.7 1.7 0 00.34 1.87l.06.06-1.7 2.94-.08-.02a1.7 1.7 0 00-1.72.58l-.05.07h-3.4l-.04-.08a1.7 1.7 0 00-1.7-.6l-.09.03-1.7-2.94.06-.07A1.7 1.7 0 009.7 15l-.08-.05v-3.4l.08-.04a1.7 1.7 0 00.6-1.7l-.03-.09 2.94-1.7.07.06a1.7 1.7 0 001.87.34l.06-.04h3.4l.04.08a1.7 1.7 0 001.7.6l.09-.03 1.7 2.94-.06.07A1.7 1.7 0 0021.8 14l.08.05v3.4z',
} as const;
export type IconName = keyof typeof PATHS;