feat(web): open spaces as a plain chat on phones with a side drawer
Spaces, channels, files, members and the account move into a left drawer, the top bar keeps only the essentials, and touch devices get a tap menu instead of always-visible delete buttons. Also fixes the history checkboxes in the channel settings sheet.
This commit is contained in:
1 parent
06fe077ef1
commit
1db3795d6a
16 files changed
+383
-56
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, type MouseEvent } from 'react';
|
||||
import { isLocalId, useWorkspace } from '@/store/workspace';
|
||||
import { useAccount } from '@/store/account';
|
||||
import { useDownload } from '@/features/item/download';
|
||||
@@ -7,6 +7,7 @@ import { senderLabel } from '@/lib/sender';
|
||||
import { formatSize, formatTime, formatTransferProgress } from '@/lib/format';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { ContextMenu, type MenuAction } from '@/ui/ContextMenu';
|
||||
import styles from './ChatPane.module.css';
|
||||
|
||||
interface Props { id: string; compact?: boolean; onOpenFile: (id: string) => void; onDelete: (id: string) => void }
|
||||
@@ -18,6 +19,7 @@ export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: Props
|
||||
const username = useAccount((s) => s.username);
|
||||
const download = useDownload();
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [menu, setMenu] = useState<{ x: number; y: number }>();
|
||||
if (!item) return null;
|
||||
const sender = item.author;
|
||||
const name = senderLabel(item);
|
||||
@@ -31,7 +33,17 @@ export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: Props
|
||||
if (!item || downloading) return;
|
||||
setDownloading(true); try { await download(item); } finally { setDownloading(false); }
|
||||
}
|
||||
return <article className={`${styles.message} ${compact ? styles.compact : ''} ${sending ? styles.sending : ''}`}
|
||||
// 触屏没有悬停:点一下消息弹出操作菜单(桌面仍是悬停按钮)
|
||||
const actions: MenuAction[] = [
|
||||
...(item.kind === 'text' ? [{ label: '复制', icon: 'copy' as const, action: () => { void navigator.clipboard?.writeText(item.text).catch(() => undefined); } }] : []),
|
||||
...(owner || self ? [{ label: '删除', icon: 'trash' as const, action: () => onDelete(id) }] : []),
|
||||
];
|
||||
function tap(event: MouseEvent<HTMLElement>) {
|
||||
if (!matchMedia('(hover: none)').matches || !actions.length || sending) return;
|
||||
if ((event.target as HTMLElement).closest('button, a, input, progress') || window.getSelection()?.toString()) return;
|
||||
setMenu({ x: event.clientX, y: event.clientY });
|
||||
}
|
||||
return <article onClick={tap} className={`${styles.message} ${compact ? styles.compact : ''} ${sending ? styles.sending : ''}`}
|
||||
aria-label={item.kind === 'file' ? `${name} 上传了 ${item.name}` : `${name} 的消息`} aria-busy={sending}>
|
||||
{compact ? <time className={styles.gutterTime} dateTime={new Date(item.ts).toISOString()}>{formatTime(item.ts)}</time>
|
||||
: sender?.avatar ? <img className={styles.avatar} src={sender.avatar} alt={`${name} 的头像`} />
|
||||
@@ -52,5 +64,6 @@ export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: Props
|
||||
: item.state.kind === 'incomplete' ? <span className={styles.delivery}>发送未完成,等待续传。</span> : null}
|
||||
</div>
|
||||
{owner || self ? <Button className={styles.deleteAction} variant="ghost" icon="trash" label={deleteLabel} onClick={() => onDelete(id)} /> : null}
|
||||
{menu ? <ContextMenu point={menu} actions={actions} onClose={() => setMenu(undefined)} /> : null}
|
||||
</article>;
|
||||
}
|
||||
@@ -278,6 +278,11 @@
|
||||
}
|
||||
|
||||
@media (width <= 899px) {
|
||||
/* 手机顶栏已显示频道名 */
|
||||
.head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.message {
|
||||
grid-template-columns: 32px minmax(0, 1fr);
|
||||
column-gap: var(--space-2);
|
||||
@@ -298,8 +303,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 触屏没有悬停:隐藏常驻按钮,点一下消息弹出菜单 */
|
||||
@media (hover: none) {
|
||||
.deleteAction {
|
||||
opacity: 1;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
.scroll {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-3);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
margin: 0 0 var(--space-1);
|
||||
padding-inline: var(--space-2);
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
min-height: 44px;
|
||||
padding: 0 var(--space-2);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.row span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.row small {
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
padding-bottom: max(var(--space-2), env(safe-area-inset-bottom));
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useChannels } from '@/store/channels';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
import { AccountDock } from '@/features/home/AccountDock';
|
||||
import { Icon, type IconName } from '@/ui/Icon';
|
||||
import { ThemeMenu } from '@/ui/ThemeMenu';
|
||||
import { SpaceHeader, SpaceRail } from './SpaceRail';
|
||||
import styles from './MobileDrawer.module.css';
|
||||
|
||||
interface Props {
|
||||
rail: { id?: string; onCreate: () => void; onJoin: () => void };
|
||||
onSettings: () => void;
|
||||
/** 频道列表(ThreadSidebar),由 Workspace 组装 */
|
||||
threads: React.ReactNode;
|
||||
onFiles: () => void;
|
||||
onMembers: () => void;
|
||||
onInvite?: () => void;
|
||||
onShare: () => void;
|
||||
onHome: () => void;
|
||||
}
|
||||
|
||||
function Row({ icon, label, count, onClick }: { icon: IconName; label: string; count?: string; onClick: () => void }) {
|
||||
return <button type="button" className={styles.row} onClick={onClick}>
|
||||
<Icon name={icon} size={18} /><span>{label}</span>{count ? <small>{count}</small> : null}
|
||||
</button>;
|
||||
}
|
||||
|
||||
/** 手机侧边栏:空间、频道、本频道的文件与成员;账号与外观在底部 */
|
||||
export function MobileDrawer({ rail, onSettings, threads, onFiles, onMembers, onInvite, onShare, onHome }: Props) {
|
||||
const used = useWorkspace((s) => s.channel?.membersUsed);
|
||||
const max = useWorkspace((s) => s.channel?.maxMembers);
|
||||
const spaces = useChannels((s) => s.list.length);
|
||||
return <>
|
||||
<SpaceRail {...rail} />
|
||||
<SpaceHeader onSettings={onSettings} />
|
||||
<div className={styles.scroll}>
|
||||
{threads}
|
||||
<nav className={styles.section} aria-label="本频道">
|
||||
<h2>本空间</h2>
|
||||
<Row icon="folder" label="文件" onClick={onFiles} />
|
||||
<Row icon="users" label="成员" count={used !== undefined ? `${used}/${max ?? '—'}` : undefined} onClick={onMembers} />
|
||||
{onInvite ? <Row icon="plus" label="邀请成员" onClick={onInvite} /> : null}
|
||||
<Row icon="share" label="分享" onClick={onShare} />
|
||||
<Row icon="chevronLeft" label={spaces > 1 ? '回到首页' : '返回首页'} onClick={onHome} />
|
||||
</nav>
|
||||
</div>
|
||||
<footer className={styles.footer}><AccountDock inline /><ThemeMenu /></footer>
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
.bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
min-height: var(--bar-h);
|
||||
padding: 0 var(--space-2);
|
||||
padding-top: env(safe-area-inset-top);
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--glass);
|
||||
/* stylelint-disable-next-line property-no-vendor-prefix */
|
||||
-webkit-backdrop-filter: var(--blur-bar);
|
||||
backdrop-filter: var(--blur-bar);
|
||||
}
|
||||
|
||||
.raw {
|
||||
border-bottom-color: var(--warn);
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lock {
|
||||
display: inline-flex;
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.warn {
|
||||
padding: 1px var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--warn-soft);
|
||||
color: var(--warn);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.conn {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.reconnecting {
|
||||
color: var(--busy);
|
||||
}
|
||||
|
||||
.offline {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--busy);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import styles from './MobileTopBar.module.css';
|
||||
|
||||
interface Props {
|
||||
/** 聊天页是 ≡(开侧边栏);文件、成员页是 ‹(回聊天) */
|
||||
view: 'chat' | 'files' | 'members';
|
||||
title: string;
|
||||
searchOpen: boolean;
|
||||
filtering: boolean;
|
||||
onMenu: () => void;
|
||||
onBack: () => void;
|
||||
onToggleSearch: () => void;
|
||||
onMore: () => void;
|
||||
}
|
||||
|
||||
/** 手机顶栏:只放最必要的几项,其余收进「更多」 */
|
||||
export function MobileTopBar({ view, title, searchOpen, filtering, onMenu, onBack, onToggleSearch, onMore }: Props) {
|
||||
const connection = useWorkspace((s) => s.connection);
|
||||
const raw = useWorkspace((s) => s.channel?.mode === 'raw');
|
||||
return <header className={`${styles.bar} ${raw ? styles.raw : ''}`}>
|
||||
{view === 'chat' ? <Button variant="ghost" icon="menu" label="打开侧边栏" onClick={onMenu} /> : <Button variant="ghost" icon="chevronLeft" label="返回聊天" onClick={onBack} />}
|
||||
<h1 className={styles.title}>{title}</h1>
|
||||
{raw ? <span className={styles.warn}>明文</span> : <span className={styles.lock} title="端到端加密"><Icon name="lock" size={14} /></span>}
|
||||
{connection === 'online' ? null : <span className={`${styles.conn} ${styles[connection]}`} role="status">{connection === 'offline' ? '离线' : '重连中'}</span>}
|
||||
<Button variant="ghost" icon="search" label="搜索文件" aria-pressed={searchOpen} className={filtering ? styles.active : undefined} onClick={onToggleSearch} />
|
||||
<Button variant="ghost" icon="more" label="更多" onClick={onMore} />
|
||||
</header>;
|
||||
}
|
||||
@@ -88,11 +88,6 @@
|
||||
}
|
||||
|
||||
@media (width <= 899px) {
|
||||
/* 手机顶栏已有空间名与设置入口 */
|
||||
.header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rail {
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
|
||||
@@ -318,16 +318,6 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.threads {
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.thread {
|
||||
flex: 0 0 auto;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.folders {
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
@@ -338,3 +328,22 @@
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 勾选项:框在左、文字在右,同一行 */
|
||||
.channelEditor .check {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-height: 36px;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.channelEditor .check input {
|
||||
flex: 0 0 18px;
|
||||
width: 18px;
|
||||
min-height: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
@@ -91,8 +91,8 @@ export function ThreadSidebar(props: Props) {
|
||||
<option value="">未分组</option>{props.groups.map((group) => <option key={group.id} value={group.id}>{group.name}</option>)}
|
||||
</select></label> : null}
|
||||
{edit.kind === 'channel' && edit.id && props.owner ? <>
|
||||
<label><input type="checkbox" checked={chatHistory} onChange={event => setChatHistory(event.target.checked)} />新成员可读历史聊天</label>
|
||||
<label><input type="checkbox" checked={filesHistory} onChange={event => setFilesHistory(event.target.checked)} />新成员可读历史文件</label>
|
||||
<label className={styles.check}><input type="checkbox" checked={chatHistory} onChange={event => setChatHistory(event.target.checked)} />新成员可读历史聊天</label>
|
||||
<label className={styles.check}><input type="checkbox" checked={filesHistory} onChange={event => setFilesHistory(event.target.checked)} />新成员可读历史文件</label>
|
||||
</> : null}
|
||||
{error ? <p className={styles.error} role="alert">{error}</p> : null}
|
||||
<div className={`${styles.groupActions} ${styles.modalActions}`}>
|
||||
|
||||
@@ -65,38 +65,11 @@
|
||||
padding-inline: env(safe-area-inset-left) env(safe-area-inset-right);
|
||||
}
|
||||
|
||||
.mobile .navigation {
|
||||
max-height: 30dvh;
|
||||
flex-shrink: 0;
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.mobileContent {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
flex: 1;
|
||||
min-height: 44px;
|
||||
border: 0;
|
||||
background: var(--surface);
|
||||
color: var(--text-dim);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tabs button[aria-pressed="true"] {
|
||||
border-bottom: 2px solid var(--accent);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.pendingCatalog {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-top: 1px solid var(--line);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { lazy, Suspense, useCallback, useState, type CSSProperties, type DragEvent } from 'react';
|
||||
import { lazy, Suspense, useCallback, useEffect, useState, type CSSProperties, type DragEvent } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { channelPath, navigate, sharePath } from '@/app/navigation';
|
||||
import { useDownload } from '@/features/item/download';
|
||||
@@ -10,7 +10,11 @@ import type { FileUploadOptions } from '@/core-bridge/types';
|
||||
import type { ClipboardPayload } from '@/lib/clipboard';
|
||||
import { Sheet } from '@/ui/Sheet';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Drawer } from '@/ui/Drawer';
|
||||
import { Icon, type IconName } from '@/ui/Icon';
|
||||
import { SpaceHeader, SpaceRail } from './SpaceRail';
|
||||
import { MobileDrawer } from './MobileDrawer';
|
||||
import { MobileTopBar } from './MobileTopBar';
|
||||
import { ThreadSidebar } from './ThreadSidebar';
|
||||
import { ChatPane } from './ChatPane';
|
||||
import { FileExplorer } from './FileExplorer';
|
||||
@@ -66,6 +70,8 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
|
||||
const [mobileView, setMobileView] = useState<'chat' | 'files' | 'members'>('chat');
|
||||
const [sideTab, setSideTab] = useState<SideTab>('files');
|
||||
const [inviteOpen, setInviteOpen] = useState(false);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const [memberRevision, setMemberRevision] = useState(0);
|
||||
const [fileWidth, setFileWidth] = useState<number>();
|
||||
const [upload, setUpload] = useState<{ files: File[]; threadId: string; folderId?: string }>();
|
||||
@@ -110,11 +116,19 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
|
||||
}
|
||||
function drop(event: DragEvent) { event.preventDefault(); setDragging(false); requestFiles([...event.dataTransfer.files]); }
|
||||
function deleteItem(id: string) { void deleteFiles([id]).catch((cause: unknown) => useWorkspace.setState({ error: cause instanceof Error ? cause.message : '删除失败' })); }
|
||||
// 手机聊天页的账号入口在侧边栏,全局浮窗据此让位
|
||||
useEffect(() => {
|
||||
if (desktop) return;
|
||||
document.documentElement.dataset.chatShell = '';
|
||||
return () => { delete document.documentElement.dataset.chatShell; };
|
||||
}, [desktop]);
|
||||
const channelId = channel?.id;
|
||||
useEffect(() => { setDrawerOpen(false); }, [channelId]);
|
||||
const rail = <SpaceRail id={channel?.id} onCreate={onCreateChannel} onJoin={() => setScanOpen(true)} />;
|
||||
const navigation = <><SpaceHeader onSettings={() => setSettingsOpen(true)} />
|
||||
<ThreadSidebar threads={threads} groups={groups} selectedThreadId={activeThreadId} enabled={Boolean(channel?.canManageStructure)} owner={channel?.role === 'owner'} onHistory={setChannelHistory} onSelectThread={selectThread} onCreateThread={createThread} onRenameThread={renameThread}
|
||||
onCreateGroup={createGroup} onRenameGroup={renameGroup} onGroupThread={groupThread} />
|
||||
{desktop ? <Notepad /> : null}</>;
|
||||
const threadSidebar = <ThreadSidebar threads={threads} groups={groups} selectedThreadId={activeThreadId} enabled={Boolean(channel?.canManageStructure)} owner={channel?.role === 'owner'} onHistory={setChannelHistory}
|
||||
onSelectThread={(id) => { selectThread(id); setDrawerOpen(false); setMobileView('chat'); }} onCreateThread={createThread} onRenameThread={renameThread}
|
||||
onCreateGroup={createGroup} onRenameGroup={renameGroup} onGroupThread={groupThread} />;
|
||||
const navigation = <><SpaceHeader onSettings={() => setSettingsOpen(true)} />{threadSidebar}{desktop ? <Notepad /> : null}</>;
|
||||
const chat = <ChatPane threadName={activeThread?.name ?? '选择频道'} ids={messages} enabled={enabled} onSend={(text) => sendText(text, activeThreadId)} onDelete={deleteItem} onOpenFile={setPreviewId} onFiles={requestFiles} />;
|
||||
const explorer = <>
|
||||
<FilterBar filter={filter} searchOpen={searchOpen} onChange={setFilter} />
|
||||
@@ -129,13 +143,28 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
|
||||
{restricted.length ? <div className={styles.pendingCatalog}><span>等待同步文件目录</span>{restricted.map((id) => <Button variant="ghost" key={id} onClick={() => setPreviewId(id)}>查看受限文件</Button>)}</div> : null}
|
||||
</>;
|
||||
const members = <MembersPane owner={owner} refreshKey={memberRevision} onInvite={() => setInviteOpen(true)} />;
|
||||
function goMobile(view: 'chat' | 'files' | 'members') { setMobileView(view); setDrawerOpen(false); }
|
||||
const moreRows: { icon: IconName; label: string; run: () => void }[] = [
|
||||
{ icon: 'settings', label: '空间设置', run: () => setSettingsOpen(true) },
|
||||
{ icon: 'share', label: '分享', run: () => setPanelOpen(true) },
|
||||
...(owner ? [{ icon: 'plus' as const, label: '邀请成员', run: () => setInviteOpen(true) }] : []),
|
||||
];
|
||||
const mobileTitle = mobileView === 'files' ? '文件' : mobileView === 'members' ? '成员' : `# ${activeThread?.name ?? '选择频道'}`;
|
||||
function openMembers() { if (desktop) setSideTab('members'); else setMobileView('members'); }
|
||||
return <div className={desktop ? styles.desktop : styles.mobile} style={fileWidth ? { '--files-width': `${fileWidth}px` } as CSSProperties : undefined}
|
||||
onDropCapture={() => setDragging(false)} onDragEndCapture={() => setDragging(false)}
|
||||
onDragOver={(event) => { if (event.dataTransfer.types.includes('Files')) { event.preventDefault(); setDragging(true); } }} onDragLeave={() => setDragging(false)} onDrop={drop}>
|
||||
<TopBar onBack={() => navigate('/')} onOpenMembers={openMembers} onInvite={owner ? () => { openMembers(); setInviteOpen(true); } : undefined} onOpenPanel={() => setPanelOpen(true)} onOpenSettings={() => setSettingsOpen(true)} searchOpen={searchOpen} filtering={isFiltering(filter)} onToggleSearch={() => { setSearchOpen((value) => !value); if (!desktop) setMobileView('files'); }} />
|
||||
{desktop ? <TopBar onBack={() => navigate('/')} onOpenMembers={openMembers} onInvite={owner ? () => { openMembers(); setInviteOpen(true); } : undefined} onOpenPanel={() => setPanelOpen(true)} onOpenSettings={() => setSettingsOpen(true)} searchOpen={searchOpen} filtering={isFiltering(filter)} onToggleSearch={() => { setSearchOpen((value) => !value); if (!desktop) setMobileView('files'); }} /> : <MobileTopBar view={mobileView} title={mobileTitle} searchOpen={searchOpen} filtering={isFiltering(filter)} onMenu={() => setDrawerOpen(true)} onBack={() => setMobileView('chat')}
|
||||
onToggleSearch={() => { setSearchOpen((value) => !value); setMobileView('files'); }} onMore={() => setMoreOpen(true)} />}
|
||||
{desktop ? <><aside className={styles.rail}>{rail}</aside><aside className={styles.navigation} data-space-nav>{navigation}</aside><main className={styles.center}>{chat}</main><FilePaneResize width={fileWidth} onWidth={setFileWidth} /><aside className={styles.files} data-file-pane><PaneTabs tab={sideTab} memberCount={channel?.membersUsed} onTab={setSideTab} />{sideTab === 'files' ? explorer : members}</aside></>
|
||||
: <>{rail}<aside className={styles.navigation}>{navigation}</aside><nav className={styles.tabs} aria-label="频道视图"><button type="button" aria-pressed={mobileView === 'chat'} onClick={() => setMobileView('chat')}>聊天</button><button type="button" aria-pressed={mobileView === 'files'} onClick={() => setMobileView('files')}>文件</button><button type="button" aria-pressed={mobileView === 'members'} onClick={() => setMobileView('members')}>成员</button></nav><main className={styles.mobileContent}>{mobileView === 'chat' ? chat : mobileView === 'files' ? explorer : members}</main></>}
|
||||
: <main className={styles.mobileContent}>{mobileView === 'chat' ? chat : mobileView === 'files' ? explorer : members}</main>}
|
||||
{/* 弹层打开时抽屉留在下面:同时关抽屉(history.back)会把刚压入的弹层历史一并弹掉 */}
|
||||
{!desktop && drawerOpen ? <Drawer label="侧边栏" onClose={() => setDrawerOpen(false)}>
|
||||
<MobileDrawer rail={{ id: channel?.id, onCreate: onCreateChannel, onJoin: () => setScanOpen(true) }}
|
||||
onSettings={() => setSettingsOpen(true)} threads={threadSidebar} onFiles={() => goMobile('files')} onMembers={() => goMobile('members')}
|
||||
onInvite={owner ? () => setInviteOpen(true) : undefined} onShare={() => setPanelOpen(true)} onHome={() => navigate('/')} />
|
||||
</Drawer> : null}
|
||||
{!desktop && moreOpen ? <Sheet title="更多" onClose={() => setMoreOpen(false)}>{moreRows.map((row) => <Button key={row.label} variant="ghost" onClick={() => { setMoreOpen(false); row.run(); }}><Icon name={row.icon} size={18} />{row.label}</Button>)}</Sheet> : null}
|
||||
{error ? <div className={styles.error} role="alert">{error}</div> : null}
|
||||
{inviteOpen ? <InviteSheet onClose={() => setInviteOpen(false)} onInvited={() => setMemberRevision((value) => value + 1)} /> : null}
|
||||
{panelOpen ? <Sheet title="分享" onClose={() => setPanelOpen(false)}><SharePanel /></Sheet> : null}
|
||||
|
||||
@@ -127,3 +127,32 @@
|
||||
min-height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 手机聊天页由侧边栏承载账号入口,浮窗让位 */
|
||||
@media (width <= 899px) {
|
||||
/* stylelint-disable-next-line selector-pseudo-class-no-unknown */
|
||||
:global([data-chat-shell]) .dock:not(.inline) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.inline {
|
||||
position: static;
|
||||
flex: 1;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.inline .profile {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
justify-content: flex-start;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
}
|
||||
|
||||
.inline .profileText {
|
||||
display: flex;
|
||||
}
|
||||
@@ -5,7 +5,8 @@ import { Sheet } from '@/ui/Sheet';
|
||||
import { AccountSettings } from './AccountSettings';
|
||||
import styles from './AccountDock.module.css';
|
||||
|
||||
export function AccountDock() {
|
||||
/** inline:嵌进手机侧边栏底部,不再浮在页面上 */
|
||||
export function AccountDock({ inline = false }: { inline?: boolean }) {
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const username = useAccount((s) => s.username);
|
||||
const personal = useAccount((s) => s.personal);
|
||||
@@ -17,7 +18,7 @@ export function AccountDock() {
|
||||
const openSettings = () => setSettingsOpen(true);
|
||||
|
||||
return <>
|
||||
<section className={styles.dock} aria-label="当前账号">
|
||||
<section className={`${styles.dock} ${inline ? styles.inline : ''}`} aria-label="当前账号">
|
||||
<button className={styles.profile} type="button" onClick={openSettings} aria-label={`打开 ${displayName} 的账号设置`}>
|
||||
{personal?.avatar ? <img className={styles.avatar} src={personal.avatar} alt="" /> : <span className={styles.avatarFallback}>{initial}</span>}
|
||||
<span className={styles.profileText}><strong className={styles.profileName}>{displayName}</strong><span className={styles.username}>{subtitle}</span></span>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
.scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
background: var(--scrim);
|
||||
animation: drawer-scrim-in 0.15s ease-out;
|
||||
}
|
||||
|
||||
.drawer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(320px, 86vw);
|
||||
height: 100%;
|
||||
padding-top: env(safe-area-inset-top);
|
||||
border-right: 1px solid var(--line);
|
||||
background: var(--bg);
|
||||
box-shadow: var(--shadow-sheet);
|
||||
outline: none;
|
||||
animation: drawer-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes drawer-scrim-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes drawer-in {
|
||||
from {
|
||||
translate: -24px 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.scrim,
|
||||
.drawer {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useEffect, useRef, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useBackToClose } from '@/lib/useBackToClose';
|
||||
import styles from './Drawer.module.css';
|
||||
|
||||
interface Props { label: string; onClose: () => void; children: ReactNode }
|
||||
|
||||
/** 手机的左侧抽屉:遮罩是纯色,返回键关闭(与 Sheet 一致) */
|
||||
export function Drawer({ label, onClose, children }: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const close = useRef(onClose);
|
||||
useEffect(() => { close.current = onClose; }, [onClose]);
|
||||
useBackToClose(onClose);
|
||||
useEffect(() => {
|
||||
const prev = document.activeElement as HTMLElement | null;
|
||||
ref.current?.focus();
|
||||
const onKey = (event: KeyboardEvent) => { if (event.key === 'Escape') close.current(); };
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => { window.removeEventListener('keydown', onKey); prev?.focus(); };
|
||||
}, []);
|
||||
return createPortal(
|
||||
<div className={styles.scrim} onClick={onClose}>
|
||||
<div ref={ref} tabIndex={-1} role="dialog" aria-modal="true" aria-label={label} className={styles.drawer} onClick={(event) => event.stopPropagation()}>
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,8 @@ const PATHS = {
|
||||
phone: 'M8 3h8v18H8z M11 18h2',
|
||||
tablet: 'M5 4h14v16H5z M11 17h2',
|
||||
more: 'M5 12h.01 M12 12h.01 M19 12h.01',
|
||||
menu: 'M4 7h16 M4 12h16 M4 17h16',
|
||||
users: 'M9 11a3 3 0 100-6 3 3 0 000 6z M3 19a6 6 0 0112 0 M16 6a3 3 0 010 6 M18 19a5 5 0 00-3-4.6',
|
||||
undo: 'M9 14L4 9l5-5 M4 9h10a6 6 0 010 12h-3',
|
||||
warn: 'M12 4l9 16H3z M12 10v4 M12 17h.01',
|
||||
sun: 'M12 8a4 4 0 100 8 4 4 0 000-8z M12 2v2 M12 20v2 M2 12h2 M20 12h2 M5 5l1.5 1.5 M17.5 17.5L19 19 M5 19l1.5-1.5 M17.5 6.5L19 5',
|
||||
|
||||
Reference in new issue
Block a user