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 { isLocalId, useWorkspace } from '@/store/workspace';
|
||||||
import { useAccount } from '@/store/account';
|
import { useAccount } from '@/store/account';
|
||||||
import { useDownload } from '@/features/item/download';
|
import { useDownload } from '@/features/item/download';
|
||||||
@@ -7,6 +7,7 @@ import { senderLabel } from '@/lib/sender';
|
|||||||
import { formatSize, formatTime, formatTransferProgress } from '@/lib/format';
|
import { formatSize, formatTime, formatTransferProgress } from '@/lib/format';
|
||||||
import { Icon } from '@/ui/Icon';
|
import { Icon } from '@/ui/Icon';
|
||||||
import { Button } from '@/ui/Button';
|
import { Button } from '@/ui/Button';
|
||||||
|
import { ContextMenu, type MenuAction } from '@/ui/ContextMenu';
|
||||||
import styles from './ChatPane.module.css';
|
import styles from './ChatPane.module.css';
|
||||||
|
|
||||||
interface Props { id: string; compact?: boolean; onOpenFile: (id: string) => void; onDelete: (id: string) => void }
|
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 username = useAccount((s) => s.username);
|
||||||
const download = useDownload();
|
const download = useDownload();
|
||||||
const [downloading, setDownloading] = useState(false);
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
const [menu, setMenu] = useState<{ x: number; y: number }>();
|
||||||
if (!item) return null;
|
if (!item) return null;
|
||||||
const sender = item.author;
|
const sender = item.author;
|
||||||
const name = senderLabel(item);
|
const name = senderLabel(item);
|
||||||
@@ -31,7 +33,17 @@ export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: Props
|
|||||||
if (!item || downloading) return;
|
if (!item || downloading) return;
|
||||||
setDownloading(true); try { await download(item); } finally { setDownloading(false); }
|
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}>
|
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>
|
{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} 的头像`} />
|
: 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}
|
: item.state.kind === 'incomplete' ? <span className={styles.delivery}>发送未完成,等待续传。</span> : null}
|
||||||
</div>
|
</div>
|
||||||
{owner || self ? <Button className={styles.deleteAction} variant="ghost" icon="trash" label={deleteLabel} onClick={() => onDelete(id)} /> : null}
|
{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>;
|
</article>;
|
||||||
}
|
}
|
||||||
@@ -278,6 +278,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (width <= 899px) {
|
@media (width <= 899px) {
|
||||||
|
/* 手机顶栏已显示频道名 */
|
||||||
|
.head {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
grid-template-columns: 32px minmax(0, 1fr);
|
grid-template-columns: 32px minmax(0, 1fr);
|
||||||
column-gap: var(--space-2);
|
column-gap: var(--space-2);
|
||||||
@@ -298,8 +303,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 触屏没有悬停:隐藏常驻按钮,点一下消息弹出菜单 */
|
||||||
@media (hover: none) {
|
@media (hover: none) {
|
||||||
.deleteAction {
|
.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) {
|
@media (width <= 899px) {
|
||||||
/* 手机顶栏已有空间名与设置入口 */
|
|
||||||
.header {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rail {
|
.rail {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
|
|||||||
@@ -318,16 +318,6 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.threads {
|
|
||||||
flex-direction: row;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thread {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
max-width: 220px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.folders {
|
.folders {
|
||||||
max-height: 120px;
|
max-height: 120px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -338,3 +328,22 @@
|
|||||||
opacity: 1;
|
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>)}
|
<option value="">未分组</option>{props.groups.map((group) => <option key={group.id} value={group.id}>{group.name}</option>)}
|
||||||
</select></label> : null}
|
</select></label> : null}
|
||||||
{edit.kind === 'channel' && edit.id && props.owner ? <>
|
{edit.kind === 'channel' && edit.id && props.owner ? <>
|
||||||
<label><input type="checkbox" checked={chatHistory} onChange={event => setChatHistory(event.target.checked)} />新成员可读历史聊天</label>
|
<label className={styles.check}><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={filesHistory} onChange={event => setFilesHistory(event.target.checked)} />新成员可读历史文件</label>
|
||||||
</> : null}
|
</> : null}
|
||||||
{error ? <p className={styles.error} role="alert">{error}</p> : null}
|
{error ? <p className={styles.error} role="alert">{error}</p> : null}
|
||||||
<div className={`${styles.groupActions} ${styles.modalActions}`}>
|
<div className={`${styles.groupActions} ${styles.modalActions}`}>
|
||||||
|
|||||||
@@ -65,38 +65,11 @@
|
|||||||
padding-inline: env(safe-area-inset-left) env(safe-area-inset-right);
|
padding-inline: env(safe-area-inset-left) env(safe-area-inset-right);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile .navigation {
|
|
||||||
max-height: 30dvh;
|
|
||||||
flex-shrink: 0;
|
|
||||||
border-right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobileContent {
|
.mobileContent {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
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 {
|
.pendingCatalog {
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
border-top: 1px solid var(--line);
|
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 { useShallow } from 'zustand/react/shallow';
|
||||||
import { channelPath, navigate, sharePath } from '@/app/navigation';
|
import { channelPath, navigate, sharePath } from '@/app/navigation';
|
||||||
import { useDownload } from '@/features/item/download';
|
import { useDownload } from '@/features/item/download';
|
||||||
@@ -10,7 +10,11 @@ import type { FileUploadOptions } from '@/core-bridge/types';
|
|||||||
import type { ClipboardPayload } from '@/lib/clipboard';
|
import type { ClipboardPayload } from '@/lib/clipboard';
|
||||||
import { Sheet } from '@/ui/Sheet';
|
import { Sheet } from '@/ui/Sheet';
|
||||||
import { Button } from '@/ui/Button';
|
import { Button } from '@/ui/Button';
|
||||||
|
import { Drawer } from '@/ui/Drawer';
|
||||||
|
import { Icon, type IconName } from '@/ui/Icon';
|
||||||
import { SpaceHeader, SpaceRail } from './SpaceRail';
|
import { SpaceHeader, SpaceRail } from './SpaceRail';
|
||||||
|
import { MobileDrawer } from './MobileDrawer';
|
||||||
|
import { MobileTopBar } from './MobileTopBar';
|
||||||
import { ThreadSidebar } from './ThreadSidebar';
|
import { ThreadSidebar } from './ThreadSidebar';
|
||||||
import { ChatPane } from './ChatPane';
|
import { ChatPane } from './ChatPane';
|
||||||
import { FileExplorer } from './FileExplorer';
|
import { FileExplorer } from './FileExplorer';
|
||||||
@@ -66,6 +70,8 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
|
|||||||
const [mobileView, setMobileView] = useState<'chat' | 'files' | 'members'>('chat');
|
const [mobileView, setMobileView] = useState<'chat' | 'files' | 'members'>('chat');
|
||||||
const [sideTab, setSideTab] = useState<SideTab>('files');
|
const [sideTab, setSideTab] = useState<SideTab>('files');
|
||||||
const [inviteOpen, setInviteOpen] = useState(false);
|
const [inviteOpen, setInviteOpen] = useState(false);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [moreOpen, setMoreOpen] = useState(false);
|
||||||
const [memberRevision, setMemberRevision] = useState(0);
|
const [memberRevision, setMemberRevision] = useState(0);
|
||||||
const [fileWidth, setFileWidth] = useState<number>();
|
const [fileWidth, setFileWidth] = useState<number>();
|
||||||
const [upload, setUpload] = useState<{ files: File[]; threadId: string; folderId?: string }>();
|
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 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 : '删除失败' })); }
|
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 rail = <SpaceRail id={channel?.id} onCreate={onCreateChannel} onJoin={() => setScanOpen(true)} />;
|
||||||
const navigation = <><SpaceHeader onSettings={() => setSettingsOpen(true)} />
|
const threadSidebar = <ThreadSidebar threads={threads} groups={groups} selectedThreadId={activeThreadId} enabled={Boolean(channel?.canManageStructure)} owner={channel?.role === 'owner'} onHistory={setChannelHistory}
|
||||||
<ThreadSidebar threads={threads} groups={groups} selectedThreadId={activeThreadId} enabled={Boolean(channel?.canManageStructure)} owner={channel?.role === 'owner'} onHistory={setChannelHistory} onSelectThread={selectThread} onCreateThread={createThread} onRenameThread={renameThread}
|
onSelectThread={(id) => { selectThread(id); setDrawerOpen(false); setMobileView('chat'); }} onCreateThread={createThread} onRenameThread={renameThread}
|
||||||
onCreateGroup={createGroup} onRenameGroup={renameGroup} onGroupThread={groupThread} />
|
onCreateGroup={createGroup} onRenameGroup={renameGroup} onGroupThread={groupThread} />;
|
||||||
{desktop ? <Notepad /> : null}</>;
|
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 chat = <ChatPane threadName={activeThread?.name ?? '选择频道'} ids={messages} enabled={enabled} onSend={(text) => sendText(text, activeThreadId)} onDelete={deleteItem} onOpenFile={setPreviewId} onFiles={requestFiles} />;
|
||||||
const explorer = <>
|
const explorer = <>
|
||||||
<FilterBar filter={filter} searchOpen={searchOpen} onChange={setFilter} />
|
<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}
|
{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)} />;
|
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'); }
|
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}
|
return <div className={desktop ? styles.desktop : styles.mobile} style={fileWidth ? { '--files-width': `${fileWidth}px` } as CSSProperties : undefined}
|
||||||
onDropCapture={() => setDragging(false)} onDragEndCapture={() => setDragging(false)}
|
onDropCapture={() => setDragging(false)} onDragEndCapture={() => setDragging(false)}
|
||||||
onDragOver={(event) => { if (event.dataTransfer.types.includes('Files')) { event.preventDefault(); setDragging(true); } }} onDragLeave={() => setDragging(false)} onDrop={drop}>
|
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></>
|
{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}
|
{error ? <div className={styles.error} role="alert">{error}</div> : null}
|
||||||
{inviteOpen ? <InviteSheet onClose={() => setInviteOpen(false)} onInvited={() => setMemberRevision((value) => value + 1)} /> : null}
|
{inviteOpen ? <InviteSheet onClose={() => setInviteOpen(false)} onInvited={() => setMemberRevision((value) => value + 1)} /> : null}
|
||||||
{panelOpen ? <Sheet title="分享" onClose={() => setPanelOpen(false)}><SharePanel /></Sheet> : null}
|
{panelOpen ? <Sheet title="分享" onClose={() => setPanelOpen(false)}><SharePanel /></Sheet> : null}
|
||||||
|
|||||||
@@ -127,3 +127,32 @@
|
|||||||
min-height: 40px;
|
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 { AccountSettings } from './AccountSettings';
|
||||||
import styles from './AccountDock.module.css';
|
import styles from './AccountDock.module.css';
|
||||||
|
|
||||||
export function AccountDock() {
|
/** inline:嵌进手机侧边栏底部,不再浮在页面上 */
|
||||||
|
export function AccountDock({ inline = false }: { inline?: boolean }) {
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const username = useAccount((s) => s.username);
|
const username = useAccount((s) => s.username);
|
||||||
const personal = useAccount((s) => s.personal);
|
const personal = useAccount((s) => s.personal);
|
||||||
@@ -17,7 +18,7 @@ export function AccountDock() {
|
|||||||
const openSettings = () => setSettingsOpen(true);
|
const openSettings = () => setSettingsOpen(true);
|
||||||
|
|
||||||
return <>
|
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} 的账号设置`}>
|
<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>}
|
{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>
|
<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',
|
phone: 'M8 3h8v18H8z M11 18h2',
|
||||||
tablet: 'M5 4h14v16H5z M11 17h2',
|
tablet: 'M5 4h14v16H5z M11 17h2',
|
||||||
more: 'M5 12h.01 M12 12h.01 M19 12h.01',
|
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',
|
undo: 'M9 14L4 9l5-5 M4 9h10a6 6 0 010 12h-3',
|
||||||
warn: 'M12 4l9 16H3z M12 10v4 M12 17h.01',
|
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',
|
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