feat(space): implement Slack-style workspace switcher and update navigation structure
ZhenYi committed
Modified view/app/src/blocks/app-shell.tsx view/app/src/blocks/app-shell.tsx +6 −15 View view/app/src/blocks/app-shell.tsx
@@ -1,21 +1,20 @@
11import { useT } from '@app/i18n'
2import { ChevronDown, Menu, PanelLeftClose, PanelLeftOpen, X } from 'lucide-react'
2import { Menu, PanelLeftClose, PanelLeftOpen, X } from 'lucide-react'
33import { type ReactNode, useEffect, useRef, useState } from 'react'
44
55/** Shared, responsive workspace frame. Content panes own their scroll position. */
66export interface AppShellProps {
7 rail?: ReactNode
87 sideHeader?: ReactNode
98 /** Pinned rows above the "工作区" caption (e.g. the personal inbox entry). */
109 navPinned?: ReactNode
1110 nav?: ReactNode
1211 sideFooter?: ReactNode
13 /** Directory-style column (explore): the rail stays, the space sidebar goes. */
14 railOnly?: boolean
12 /** Directory-style page (explore): the whole navigation column is dropped. */
13 noSidebar?: boolean
1514 children: ReactNode
1615}
1716
18export function AppShell({ rail, sideHeader, navPinned, nav, sideFooter, railOnly = false, children }: AppShellProps) {
17export function AppShell({ sideHeader, navPinned, nav, sideFooter, noSidebar = false, children }: AppShellProps) {
1918 const { t } = useT()
2019 const [navigationOpen, setNavigationOpen] = useState(false)
2120 const [collapsed, setCollapsed] = useState(false)
@@ -61,7 +60,7 @@
6160
6261 return (
6362 <div
64 className={`workspace-shell ${collapsed ? 'navigation-collapsed' : ''} ${navigationOpen ? 'navigation-open' : ''} ${railOnly ? 'rail-only' : ''}`}
63 className={`workspace-shell ${collapsed ? 'navigation-collapsed' : ''} ${navigationOpen ? 'navigation-open' : ''} ${noSidebar ? 'no-sidebar' : ''}`}
6564 >
6665 <button
6766 type="button"
@@ -107,17 +106,9 @@
107106 }}
108107 >
109108 <div className="workspace-navigation-columns">
110 <aside className="workspace-rail" aria-label={t('ui.spaceList')}>
111 {rail}
112 </aside>
113109 <aside className="workspace-sidebar" aria-label={t('ui.currentSpace')}>
114110 <div className="workspace-sidebar-header">
115 {sideHeader ?? (
116 <>
117 <span className="min-w-0 flex-1 truncate font-semibold">{t('ui.personalSpace')}</span>
118 <ChevronDown size={16} className="text-shell-ink-muted" />
119 </>
120 )}
111 {sideHeader}
121112 <button
122113 type="button"
123114 className="workspace-drawer-close workspace-icon-button"
Modified view/app/src/domains/space/discovery/ui/explore-view.tsx view/app/src/domains/space/discovery/ui/explore-view.tsx +5 −2 View view/app/src/domains/space/discovery/ui/explore-view.tsx
@@ -5,7 +5,7 @@
55import { ApiFailure, useAsync } from '@app/lib'
66import { Button } from '@app/ui'
77import { Compass, Loader2, Search, Users } from 'lucide-react'
8import { useEffect, useState } from 'react'
8import { type ReactNode, useEffect, useState } from 'react'
99import { useNavigate } from 'react-router-dom'
1010import {
1111 type AllowJoinLevel,
@@ -130,6 +130,8 @@
130130export interface ExploreViewProps {
131131 /** Opens the create-space modal owned by the shell. */
132132 onAddSpace: () => void
133 /** Top-bar workspace switcher, injected by the workspace shell. */
134 switcher?: ReactNode
133135}
134136
135137/**
@@ -138,7 +140,7 @@
138140 * card either walks into a space or files a join request, so no sidebar list
139141 * or room prefetch applies.
140142 */
141export function ExploreView({ onAddSpace }: ExploreViewProps) {
143export function ExploreView({ onAddSpace, switcher }: ExploreViewProps) {
142144 const { t } = useT()
143145 const navigate = useNavigate()
144146 const [query, setQuery] = useState('')
@@ -231,6 +233,7 @@
231233 icon={<Compass className="h-[18px] w-[18px] shrink-0 text-shell-ink-muted" />}
232234 title={t('space.discover.title')}
233235 topic={t('space.discover.sub')}
236 actions={switcher}
234237 />
235238 <div className="flex min-h-0 flex-1 flex-col">
236239 <div className="flex flex-wrap items-center gap-3 border-b border-shell-hairline px-6 py-3">
Added view/app/src/domains/space/management/ui/space-switcher.tsx view/app/src/domains/space/management/ui/space-switcher.tsx +181 −0 View view/app/src/domains/space/management/ui/space-switcher.tsx
@@ -0,0 +1,181 @@
1import { useT } from '@app/i18n'
2import { Check, ChevronsUpDown, Compass, Layers3, Plus } from 'lucide-react'
3import { type ReactNode, useEffect, useRef, useState } from 'react'
4import { Link } from 'react-router-dom'
5import type { MySpace } from '../model/api'
6
7/** Fallback avatar glyph for a space with no picture. */
8function initial(name: string): string {
9 return name.slice(0, 1).toUpperCase()
10}
11
12export interface SpaceSwitcherProps {
13 spaces: MySpace[]
14 /** Active space name; absent on the personal home and in explore. */
15 activeName?: string
16 /** `/app/explore` is open, so neither home nor a space is current. */
17 discovering?: boolean
18 onAddSpace: () => void
19 /** Hovered a space row: let the page warm the lists behind it. */
20 onWarmSpace?: (name: string) => void
21 /** The panel is about to open; close any sibling popover first. */
22 onOpen?: () => void
23 /** Anchor the panel to the trigger's end instead of its start. */
24 align?: 'start' | 'end'
25 className?: string
26 trigger: (state: { open: boolean; toggle: () => void }) => ReactNode
27}
28
29/**
30 * Slack-style workspace switcher: the trigger lives in the sidebar header, and
31 * a click opens the account's spaces (plus home / create / discover) instead of
32 * reserving a permanent rail column for them.
33 */
34export function SpaceSwitcher({
35 spaces,
36 activeName,
37 discovering = false,
38 onAddSpace,
39 onWarmSpace,
40 onOpen,
41 align = 'start',
42 className,
43 trigger,
44}: SpaceSwitcherProps) {
45 const { t } = useT()
46 const [open, setOpen] = useState(false)
47 const root = useRef<HTMLDivElement>(null)
48 const hoverTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
49 const close = () => setOpen(false)
50 const home = !activeName && !discovering
51 const toggle = () => {
52 if (!open) onOpen?.()
53 setOpen((value) => !value)
54 }
55
56 useEffect(() => {
57 if (!open) return
58 const onDown = (event: PointerEvent) => {
59 if (!root.current?.contains(event.target as Node)) setOpen(false)
60 }
61 const onKey = (event: KeyboardEvent) => {
62 if (event.key === 'Escape') setOpen(false)
63 }
64 window.addEventListener('pointerdown', onDown)
65 window.addEventListener('keydown', onKey)
66 return () => {
67 window.removeEventListener('pointerdown', onDown)
68 window.removeEventListener('keydown', onKey)
69 }
70 }, [open])
71
72 useEffect(() => () => clearTimeout(hoverTimer.current), [])
73
74 const warm = (name: string) => {
75 clearTimeout(hoverTimer.current)
76 hoverTimer.current = setTimeout(() => onWarmSpace?.(name), 150)
77 }
78
79 return (
80 <div ref={root} className={`space-switcher${className ? ` ${className}` : ''}`}>
81 {trigger({ open, toggle })}
82 {open ? (
83 <div
84 role="menu"
85 aria-label={t('shell.switcher.label')}
86 className={`space-switcher-panel${align === 'end' ? ' is-end' : ''}`}
87 >
88 <Link
89 to="/app/@me"
90 role="menuitem"
91 aria-current={home ? 'page' : undefined}
92 className="space-switcher-row"
93 onClick={close}
94 >
95 <span className="space-switcher-tile is-home" aria-hidden="true">
96 <Layers3 size={15} />
97 </span>
98 <span className="space-switcher-row-name">{t('ui.personalSpace')}</span>
99 {home ? <Check size={15} className="space-switcher-mark" aria-hidden="true" /> : null}
100 </Link>
101 {spaces.map(({ space }) => (
102 <Link
103 key={space.id}
104 to={`/app/${encodeURIComponent(space.name)}`}
105 role="menuitem"
106 aria-current={space.name === activeName ? 'page' : undefined}
107 className="space-switcher-row"
108 onClick={close}
109 onMouseEnter={() => warm(space.name)}
110 >
111 <span className="space-switcher-tile" aria-hidden="true">
112 {space.avatar_url ? <img src={space.avatar_url} alt="" /> : initial(space.name)}
113 </span>
114 <span className="space-switcher-row-name">{space.name}</span>
115 {space.name === activeName ? (
116 <Check size={15} className="space-switcher-mark" aria-hidden="true" />
117 ) : null}
118 </Link>
119 ))}
120 <hr className="space-switcher-divider" />
121 <button
122 type="button"
123 role="menuitem"
124 className="space-switcher-row"
125 onClick={() => {
126 close()
127 onAddSpace()
128 }}
129 >
130 <span className="space-switcher-tile is-action" aria-hidden="true">
131 <Plus size={15} />
132 </span>
133 <span className="space-switcher-row-name">{t('shell.rail.addSpace')}</span>
134 </button>
135 <Link
136 to="/app/explore"
137 role="menuitem"
138 aria-current={discovering ? 'page' : undefined}
139 className="space-switcher-row"
140 onClick={close}
141 >
142 <span className="space-switcher-tile is-action" aria-hidden="true">
143 <Compass size={15} />
144 </span>
145 <span className="space-switcher-row-name">{t('shell.rail.discover')}</span>
146 {discovering ? <Check size={15} className="space-switcher-mark" aria-hidden="true" /> : null}
147 </Link>
148 </div>
149 ) : null}
150 </div>
151 )
152}
153
154/** Shared trigger body for the personal header and the explore top bar. */
155export function SpaceSwitcherTrigger({
156 open,
157 toggle,
158 label,
159 compact = false,
160}: {
161 open: boolean
162 toggle: () => void
163 label: ReactNode
164 compact?: boolean
165}) {
166 return (
167 <button
168 type="button"
169 aria-haspopup="menu"
170 aria-expanded={open}
171 onClick={toggle}
172 className={`space-switcher-trigger${compact ? ' is-compact' : ''}`}
173 >
174 <span className="space-switcher-trigger-tile" aria-hidden="true">
175 <Layers3 size={compact ? 15 : 16} />
176 </span>
177 <span className="space-switcher-trigger-label">{label}</span>
178 <ChevronsUpDown size={compact ? 14 : 15} className="space-switcher-trigger-caret" aria-hidden="true" />
179 </button>
180 )
181}
Modified view/app/src/domains/space/management/index.ts view/app/src/domains/space/management/index.ts +1 −0 View view/app/src/domains/space/management/index.ts
@@ -9,3 +9,4 @@
99 SpaceSettingsModal,
1010 type SpaceSettingsModalProps,
1111} from './ui/space-settings-modal'
12export { SpaceSwitcher, type SpaceSwitcherProps, SpaceSwitcherTrigger } from './ui/space-switcher'
Modified view/app/src/i18n/messages/en/shell.ts view/app/src/i18n/messages/en/shell.ts +1 −0 View view/app/src/i18n/messages/en/shell.ts
@@ -85,6 +85,7 @@
8585 'shell.rail.home': 'Home',
8686 'shell.rail.addSpace': 'Add a space',
8787 'shell.rail.discover': 'Discover',
88 'shell.switcher.label': 'Switch space',
8889 'shell.pending.title': 'Start collaborating here',
8990 'shell.pending.body': '{label} — keep the conversation going, share an idea, or catch up on what the team shipped.',
9091 'shell.pending.hint': 'Pick something in the workspace navigation',
Modified view/app/src/i18n/messages/zh-CN/shell.ts view/app/src/i18n/messages/zh-CN/shell.ts +1 −0 View view/app/src/i18n/messages/zh-CN/shell.ts
@@ -84,6 +84,7 @@
8484 'shell.rail.home': '主页',
8585 'shell.rail.addSpace': '添加空间',
8686 'shell.rail.discover': '发现',
87 'shell.switcher.label': '切换空间',
8788 'shell.pending.title': '让协作从这里开始',
8889 'shell.pending.body': '{label},继续讨论、分享想法,或查看团队的最新进展。',
8990 'shell.pending.hint': '在工作区导航中选择内容',
Modified view/app/src/pages/workspace/ui/layout/board-layout.tsx view/app/src/pages/workspace/ui/layout/board-layout.tsx +1 −2 View view/app/src/pages/workspace/ui/layout/board-layout.tsx
@@ -10,8 +10,7 @@
1010 * Columns are 266px wide with a tinted track; cards plug in as children.
1111 */
1212
13export interface BoardLayoutProps
14 extends Pick<AppShellProps, 'rail' | 'sideHeader' | 'navPinned' | 'nav' | 'sideFooter'> {
13export interface BoardLayoutProps extends Pick<AppShellProps, 'sideHeader' | 'navPinned' | 'nav' | 'sideFooter'> {
1514 title?: ReactNode
1615 topic?: ReactNode
1716 /** Page-level view switch (看板 / 列表), rendered beside the title. */
Modified view/app/src/pages/workspace/ui/layout/chat-layout.tsx view/app/src/pages/workspace/ui/layout/chat-layout.tsx +1 −2 View view/app/src/pages/workspace/ui/layout/chat-layout.tsx
@@ -11,8 +11,7 @@
1111 * the "pinned to the tail" read cursor and the older-page scroll anchor.
1212 */
1313
14export interface ChatLayoutProps
15 extends Pick<AppShellProps, 'rail' | 'sideHeader' | 'navPinned' | 'nav' | 'sideFooter'> {
14export interface ChatLayoutProps extends Pick<AppShellProps, 'sideHeader' | 'navPinned' | 'nav' | 'sideFooter'> {
1615 /** Top-bar title slot (the room view renders its own header row). */
1716 title?: React.ReactNode
1817 topic?: React.ReactNode
Modified view/app/src/pages/workspace/ui/layout/repo-layout.tsx view/app/src/pages/workspace/ui/layout/repo-layout.tsx +1 −2 View view/app/src/pages/workspace/ui/layout/repo-layout.tsx
@@ -28,8 +28,7 @@
2828// The tab ids belong to the route model (they are URL state, see
2929// `model/repo-location`), so this layout only consumes them.
3030
31export interface RepoLayoutProps
32 extends Pick<AppShellProps, 'rail' | 'sideHeader' | 'navPinned' | 'nav' | 'sideFooter'> {
31export interface RepoLayoutProps extends Pick<AppShellProps, 'sideHeader' | 'navPinned' | 'nav' | 'sideFooter'> {
3332 title?: ReactNode
3433 topic?: ReactNode
3534 actions?: ReactNode
Modified view/app/src/pages/workspace/ui/members-view.tsx view/app/src/pages/workspace/ui/members-view.tsx +1 −1 View view/app/src/pages/workspace/ui/members-view.tsx
@@ -11,7 +11,7 @@
1111export interface MembersViewProps {
1212 spaceName: string
1313 /** AppShell slots owned by the workspace frame. */
14 shell: Pick<AppShellProps, 'rail' | 'sideHeader' | 'navPinned' | 'nav' | 'sideFooter'>
14 shell: Pick<AppShellProps, 'sideHeader' | 'navPinned' | 'nav' | 'sideFooter'>
1515}
1616
1717const PAGE_SIZES = [25, 50, 100]
Deleted view/app/src/pages/workspace/ui/space-rail.tsx view/app/src/pages/workspace/ui/space-rail.tsx +0 −168
@@ -1,168 +0,0 @@
1import { useT } from '@app/i18n'
2import { Layers3, Plus } from 'lucide-react'
3import { type ReactNode, useEffect, useRef } from 'react'
4import { Link } from 'react-router-dom'
5import { initial } from '../model/initial'
6import { prefetchSpaceLists } from '../model/prefetch'
7import type { MySpace } from '../model/workspace'
8
9/** Space list for the AppShell `rail` slot, home / add / discover included. */
10export function SpaceRail({
11 spaces,
12 activeName,
13 discovering = false,
14 onAddSpace,
15}: {
16 spaces: MySpace[]
17 activeName?: string
18 /** `/app/explore` is open: the compass tile takes the active treatment. */
19 discovering?: boolean
20 /** Opens the create-space modal; falls back to a link when absent. */
21 onAddSpace?: () => void
22}) {
23 const { t } = useT()
24 return (
25 <>
26 <RailLink to="/app/@me" active={!activeName && !discovering} label={t('shell.rail.home')}>
27 <Layers3 className="h-[26px] w-[26px]" />
28 </RailLink>
29 <div className="h-0.5 w-8 rounded-full bg-shell-tile" />
30 {spaces.map(({ space }) => (
31 <RailLink
32 key={space.id}
33 to={`/app/${encodeURIComponent(space.name)}`}
34 active={space.name === activeName}
35 label={space.name}
36 onHover={space.name === activeName ? undefined : () => prefetchSpaceLists(space.name)}
37 >
38 {space.avatar_url ? (
39 <img src={space.avatar_url} alt="" className="h-full w-full rounded-[inherit] object-cover" />
40 ) : (
41 <span className="text-[15px] font-semibold">{initial(space.name)}</span>
42 )}
43 </RailLink>
44 ))}
45 {onAddSpace ? (
46 <RailButton onClick={onAddSpace} label={t('shell.rail.addSpace')}>
47 <Plus className="h-[22px] w-[22px]" />
48 </RailButton>
49 ) : (
50 <RailLink to="/app/explore" active={false} label={t('shell.rail.addSpace')} dashed>
51 <Plus className="h-[22px] w-[22px]" />
52 </RailLink>
53 )}
54 <RailLink to="/app/explore" active={discovering} label={t('shell.rail.discover')} dashed>
55 <CompassFilled className="h-[18px] w-[18px]" />
56 </RailLink>
57 </>
58 )
59}
60
61function RailButton({ onClick, label, children }: { onClick: () => void; label: string; children: ReactNode }) {
62 return (
63 <div className="relative flex w-12 justify-center">
64 <button
65 type="button"
66 title={label}
67 aria-label={label}
68 onClick={onClick}
69 className="group relative flex h-12 w-12 items-center justify-center overflow-hidden bg-transparent text-shell-ink transition-colors"
70 >
71 <RailDashedRing />
72 <span className="relative flex items-center justify-center">{children}</span>
73 </button>
74 </div>
75 )
76}
77
78function RailLink({
79 to,
80 active,
81 label,
82 dashed = false,
83 onHover,
84 children,
85}: {
86 to: string
87 active: boolean
88 label: string
89 /** Set the create / discover tiles apart with a dashed ring until they are used. */
90 dashed?: boolean
91 /** Hover prefetch, delayed so a cursor sweeping the rail fires nothing. */
92 onHover?: () => void
93 children: ReactNode
94}) {
95 const hoverTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
96 useEffect(() => () => clearTimeout(hoverTimer.current), [])
97 const hover = onHover
98 ? {
99 onMouseEnter: () => {
100 clearTimeout(hoverTimer.current)
101 hoverTimer.current = setTimeout(onHover, 120)
102 },
103 onFocus: () => {
104 clearTimeout(hoverTimer.current)
105 hoverTimer.current = setTimeout(onHover, 120)
106 },
107 onMouseLeave: () => clearTimeout(hoverTimer.current),
108 onBlur: () => clearTimeout(hoverTimer.current),
109 }
110 : {}
111 return (
112 <div className="relative flex w-12 justify-center">
113 {active ? (
114 <span className="absolute -left-[10px] top-1/2 h-10 w-1 -translate-y-1/2 rounded-r-full bg-shell-ink-muted" />
115 ) : null}
116 <Link
117 to={to}
118 title={label}
119 aria-label={label}
120 aria-current={active ? 'page' : undefined}
121 {...hover}
122 className={`group relative flex h-12 w-12 items-center justify-center overflow-hidden transition-colors ${
123 active
124 ? 'bg-shell-tile font-semibold text-shell-ink'
125 : dashed
126 ? 'bg-transparent text-shell-ink'
127 : 'bg-shell-tile text-shell-ink-muted hover:text-shell-ink'
128 }`}
129 >
130 {dashed ? <RailDashedRing /> : null}
131 <span className="relative flex items-center justify-center">{children}</span>
132 </Link>
133 </div>
134 )
135}
136
137/** Dashed rail ring: long, sparse segments that square off with their tile. */
138function RailDashedRing() {
139 return (
140 <svg aria-hidden viewBox="0 0 48 48" className="pointer-events-none absolute inset-0 h-full w-full text-shell-tile">
141 <rect
142 className="rail-dashed-ring-shape"
143 x="2"
144 y="2"
145 width="44"
146 height="44"
147 fill="none"
148 stroke="currentColor"
149 strokeWidth="2"
150 strokeDasharray="6 8"
151 />
152 </svg>
153 )
154}
155
156/** Filled compass: solid disc with the needle knocked out, matching the rail reference. */
157function CompassFilled({ className }: { className?: string }) {
158 return (
159 <svg viewBox="0 0 24 24" className={className} aria-hidden>
160 <path
161 fillRule="evenodd"
162 clipRule="evenodd"
163 className="fill-current"
164 d="M12 1.5a10.5 10.5 0 1 0 0 21 10.5 10.5 0 0 0 0-21Zm4.8 5.7-4.2 6.6-6.6 4.2 4.2-6.6 6.6-4.2Z"
165 />
166 </svg>
167 )
168}
Modified view/app/src/pages/workspace/ui/workspace-shell.tsx view/app/src/pages/workspace/ui/workspace-shell.tsx +39 −16 View view/app/src/pages/workspace/ui/workspace-shell.tsx
@@ -3,13 +3,19 @@
33import { NotifyNavItem, NotifyToaster, NotifyView } from '@app/domains/notifications'
44import { UserSettingsModal } from '@app/domains/settings'
55import { ExploreView } from '@app/domains/space/discovery'
6import { CreateSpaceModal, SpaceHeader, SpaceSettingsModal } from '@app/domains/space/management'
6import {
7 CreateSpaceModal,
8 SpaceHeader,
9 SpaceSettingsModal,
10 SpaceSwitcher,
11 SpaceSwitcherTrigger,
12} from '@app/domains/space/management'
713import { useT } from '@app/i18n'
814import { ApiFailure } from '@app/lib'
915import { Loader2 } from 'lucide-react'
1016import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
1117import { Navigate, useNavigate, useSearchParams } from 'react-router-dom'
12import { prefetchLikelyTargets } from '../model/prefetch'
18import { prefetchLikelyTargets, prefetchSpaceLists } from '../model/prefetch'
1319import { useWorkspaceData } from '../model/workspace-data'
1420import { useShellRoute } from '../model/workspace-route'
1521import { BoardView } from './board/board-view'
@@ -19,7 +25,6 @@
1925import { PendingPanel } from './pending-panel'
2026import { RepoView } from './repo/repo-view'
2127import { SidebarNav } from './sidebar-nav'
22import { SpaceRail } from './space-rail'
2328import { UserPanel } from './user-panel'
2429
2530/**
@@ -148,21 +153,14 @@
148153 const personal = spaceName === '@me'
149154 const canOrganize = currentMySpace?.permissions?.includes('manage_channels') ?? false
150155 const shell = {
151 // Always the real rail: fabricated placeholder tiles must not flash
152 // while `GET /me/spaces` is still in flight.
153 rail: (
154 <SpaceRail
155 spaces={spaces.data ?? []}
156 activeName={personal ? undefined : spaceName || undefined}
157 discovering={exploring}
158 onAddSpace={() => setCreatingSpace(true)}
159 />
160 ),
161156 sideHeader: currentMySpace ? (
162157 <SpaceHeader
163158 key={`${currentMySpace.space.id}:${spaceReload}`}
164159 mySpace={currentMySpace}
160 spaces={spaces.data ?? []}
165161 viewerId={me.data?.user.user_id}
162 onAddSpace={() => setCreatingSpace(true)}
163 onWarmSpace={prefetchSpaceLists}
166164 onListsChanged={() => setListsReload((key) => key + 1)}
167165 onOpenSettings={() => setSpaceSettingsOpen(true)}
168166 onLeft={() => {
@@ -170,7 +168,18 @@
170168 navigate('/app/@me')
171169 }}
172170 />
173 ) : undefined,
171 ) : (
172 <SpaceSwitcher
173 spaces={spaces.data ?? []}
174 discovering={exploring}
175 onAddSpace={() => setCreatingSpace(true)}
176 onWarmSpace={prefetchSpaceLists}
177 className="min-w-0 flex-1"
178 trigger={({ open, toggle }) => (
179 <SpaceSwitcherTrigger open={open} toggle={toggle} label={t('ui.personalSpace')} />
180 )}
181 />
182 ),
174183 // The personal inbox floats above the "工作区" caption, outside the
175184 // draggable channel list; only the @me scope shows it.
176185 navPinned: personal ? <NotifyNavItem /> : undefined,
@@ -248,8 +257,22 @@
248257 if (mode === 'explore') {
249258 return (
250259 <>
251 <AppShell {...shell} railOnly>
252 <ExploreView onAddSpace={() => setCreatingSpace(true)} />
260 <AppShell {...shell} noSidebar>
261 <ExploreView
262 onAddSpace={() => setCreatingSpace(true)}
263 switcher={
264 <SpaceSwitcher
265 spaces={spaces.data ?? []}
266 discovering
267 onAddSpace={() => setCreatingSpace(true)}
268 onWarmSpace={prefetchSpaceLists}
269 align="end"
270 trigger={({ open, toggle }) => (
271 <SpaceSwitcherTrigger compact open={open} toggle={toggle} label={t('shell.switcher.label')} />
272 )}
273 />
274 }
275 />
253276 </AppShell>
254277 {createSpaceModal}
255278 {settingsModal}
Modified view/app/src/styles/workspace.css view/app/src/styles/workspace.css +201 −62 View view/app/src/styles/workspace.css
@@ -14,7 +14,6 @@
1414 line-height: 1.6;
1515}
1616.workspace-shell {
17 --nav-rail: 76px;
1817 --nav-side: 280px;
1918 --nav-motion: 230ms;
2019 --nav-ease: cubic-bezier(0.22, 0.68, 0.28, 1);
@@ -35,8 +34,8 @@
3534 flex-direction: column;
3635 flex-shrink: 0;
3736 overflow: hidden;
38 width: calc(var(--nav-rail) + var(--nav-side));
39 background: linear-gradient(to right, var(--shell-rail) 0 var(--nav-rail), var(--shell-sidebar) var(--nav-rail) 100%);
37 width: var(--nav-side);
38 background: var(--shell-sidebar);
4039 transition: width var(--nav-motion) var(--nav-ease);
4140}
4241.workspace-navigation-columns {
@@ -44,17 +43,6 @@
4443 flex: 1;
4544 min-height: 0;
4645}
47.workspace-rail {
48 display: flex;
49 width: var(--nav-rail);
50 flex-shrink: 0;
51 flex-direction: column;
52 align-items: center;
53 gap: 14px;
54 overflow-y: auto;
55 padding: 20px 0;
56 background: var(--shell-rail);
57}
5846.workspace-sidebar {
5947 display: flex;
6048 flex-direction: column;
@@ -243,9 +231,9 @@
243231 flex-shrink: 0;
244232 align-items: center;
245233 gap: 10px;
246 margin: 8px 8px 0 0;
234 margin: 8px;
247235 padding: 12px 16px;
248 border-top-right-radius: 12px;
236 border-radius: 12px;
249237 background: var(--shell-surface);
250238 box-shadow: var(--shell-shadow);
251239}
@@ -324,19 +312,13 @@
324312 color 160ms,
325313 visibility 0s;
326314}
327/*
328 * Directory column (explore): the rail is the whole navigation, so the space
329 * sidebar is dropped and the navigation column narrows to the rail width. The
330 * mobile drawer keeps the same rule; there is nothing behind it to open.
331 */
332.rail-only .workspace-navigation {
333 width: var(--nav-rail);
334}
335.rail-only .workspace-sidebar {
315/* Directory page (explore): no space scope exists, so the shell drops its
316 * whole navigation column and the page owns the full width. */
317.no-sidebar .workspace-navigation {
336318 display: none;
337319}
338320.navigation-collapsed .workspace-navigation {
339 width: var(--nav-rail);
321 width: 0;
340322}
341323.navigation-collapsed .workspace-sidebar {
342324 opacity: 0;
@@ -345,8 +327,7 @@
345327 opacity calc(var(--nav-motion) * 0.45) ease,
346328 visibility 0s linear calc(var(--nav-motion) * 0.45);
347329}
348.navigation-collapsed .workspace-navigation-footer,
349.rail-only .workspace-navigation-footer {
330.navigation-collapsed .workspace-navigation-footer {
350331 display: none;
351332}
352333/*
@@ -533,35 +514,202 @@
533514.workspace-nav-row[data-kind="folder"] {
534515 margin-top: 12px;
535516}
536/* Rail tiles rest as circles and square off on hover or when they own the route. */
537.workspace-rail a,
538.workspace-rail button {
539 border-radius: 9999px;
540 transition:
541 background 160ms,
542 color 160ms,
543 border-color 160ms,
544 border-radius 200ms,
545 box-shadow 160ms;
517/* Slack-style workspace switcher: the trigger sits in the sidebar header and
518 * the account's spaces open as a popover instead of a permanent rail column. */
519.space-switcher {
520 position: relative;
521 min-width: 0;
522}
523.space-switcher-panel {
524 position: absolute;
525 z-index: 60;
526 top: calc(100% + 8px);
527 left: 0;
528 width: min(256px, calc(100vw - 24px));
529 max-height: min(72vh, 560px);
530 overflow-y: auto;
531 overscroll-behavior: contain;
532 padding: 6px;
533 border: 1px solid var(--shell-hairline);
534 border-radius: 14px;
535 background: var(--shell-surface);
536 box-shadow: 0 18px 48px rgb(15 17 19 / 18%);
537 transform-origin: top left;
538 animation: space-switcher-pop 150ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
539}
540.space-switcher-panel.is-end {
541 right: 0;
542 left: auto;
543 transform-origin: top right;
546544}
547.workspace-rail a:hover,
548.workspace-rail button:hover,
549.workspace-rail a[aria-current="page"] {
550 border-radius: 16px;
545@keyframes space-switcher-pop {
546 from {
547 opacity: 0;
548 transform: translateY(-6px) scale(0.98);
549 }
550 to {
551 opacity: 1;
552 transform: none;
553 }
551554}
552.workspace-rail a[aria-current="page"] {
555.space-switcher-row {
556 display: flex;
557 width: 100%;
558 min-height: 38px;
559 align-items: center;
560 gap: 10px;
561 padding: 6px 9px;
562 border-radius: 9px;
553563 color: var(--shell-ink);
564 font-size: 13.5px;
565 text-align: left;
566 transition: background 140ms;
554567}
555/* The dashed create / discover ring follows the same morph, without changing its fill. */
556.rail-dashed-ring-shape {
557 rx: 22px;
558 transition: rx 200ms;
568.space-switcher-row:hover {
569 background: var(--shell-active);
559570}
560.workspace-rail a:hover .rail-dashed-ring-shape,
561.workspace-rail button:hover .rail-dashed-ring-shape,
562.workspace-rail a[aria-current="page"] .rail-dashed-ring-shape {
563 rx: 14px;
571.space-switcher-row[aria-current="page"] {
572 background: color-mix(in srgb, var(--shell-ink) 4%, var(--shell-tile));
573 font-weight: 600;
564574}
575.space-switcher-tile {
576 display: grid;
577 width: 26px;
578 height: 26px;
579 flex-shrink: 0;
580 place-items: center;
581 overflow: hidden;
582 border-radius: 8px;
583 background: var(--shell-tile);
584 color: var(--shell-ink-muted);
585 font-size: 11.5px;
586 font-weight: 700;
587}
588.space-switcher-tile > img {
589 width: 100%;
590 height: 100%;
591 object-fit: cover;
592}
593.space-switcher-tile.is-home {
594 background: var(--shell-active);
595 color: var(--shell-ink);
596}
597.space-switcher-tile.is-action {
598 background: transparent;
599}
600.space-switcher-row-name {
601 min-width: 0;
602 flex: 1;
603 overflow: hidden;
604 text-overflow: ellipsis;
605 white-space: nowrap;
606}
607.space-switcher-mark {
608 flex-shrink: 0;
609 color: var(--shell-ink-muted);
610}
611.space-switcher-divider {
612 height: 1px;
613 margin: 6px 4px;
614 background: var(--shell-hairline);
615}
616
617/* Trigger body shared by the personal header and the explore top bar. */
618.space-switcher-trigger {
619 display: flex;
620 width: 100%;
621 min-width: 0;
622 align-items: center;
623 gap: 10px;
624 padding: 5px 8px;
625 margin-left: -8px;
626 border-radius: 10px;
627 color: var(--shell-ink);
628 text-align: left;
629 transition: background 140ms;
630}
631.space-switcher-trigger:hover {
632 background: var(--shell-active);
633}
634.space-switcher-trigger-tile {
635 display: grid;
636 width: 30px;
637 height: 30px;
638 flex-shrink: 0;
639 place-items: center;
640 border-radius: 9px;
641 background: var(--shell-tile);
642 color: var(--shell-ink);
643}
644.space-switcher-trigger-label {
645 min-width: 0;
646 flex: 1;
647 overflow: hidden;
648 font-weight: 600;
649 text-overflow: ellipsis;
650 white-space: nowrap;
651}
652.space-switcher-trigger-caret {
653 flex-shrink: 0;
654 color: var(--shell-ink-muted);
655}
656.space-switcher-trigger.is-compact {
657 width: auto;
658 margin: 0;
659 padding: 5px 10px 5px 6px;
660 border: 1px solid var(--shell-hairline);
661 font-size: 13px;
662}
663.space-switcher-trigger.is-compact .space-switcher-trigger-tile {
664 width: 24px;
665 height: 24px;
666 border-radius: 7px;
667}
668.space-switcher-trigger.is-compact .space-switcher-trigger-label {
669 flex: none;
670 max-width: 140px;
671}
672
673/* The space banner's identity block is the switcher trigger; the community
674 * menu moves to the trailing "more" button. */
675.space-community-switcher {
676 display: flex;
677 min-width: 0;
678 flex: 1;
679 align-items: center;
680 gap: 10px;
681}
682.space-community-identity {
683 display: flex;
684 max-width: 100%;
685 align-items: center;
686 gap: 6px;
687 color: #fff;
688 text-align: left;
689}
690.space-community-identity > svg {
691 flex-shrink: 0;
692 color: rgb(255 255 255 / 70%);
693}
694.space-community-identity:hover > svg {
695 color: #fff;
696}
697.space-community-more {
698 display: grid;
699 width: 30px;
700 height: 30px;
701 flex-shrink: 0;
702 place-items: center;
703 border-radius: 9px;
704 color: rgb(255 255 255 / 88%);
705 transition:
706 background 160ms,
707 color 160ms;
708}
709.space-community-more:hover {
710 background: rgb(7 10 16 / 28%);
711 color: #fff;
712}
565713/* Keyboard focus for controls. */
566714.workspace-shell :is(button, a, summary, [role="button"], [role="option"]):focus-visible,
567715.workspace-overlay :is(button, a, summary, [role="button"], [role="option"]):focus-visible {
@@ -1041,7 +1189,6 @@
10411189}
10421190@media (max-width: 1200px) {
10431191 .workspace-shell {
1044 --nav-rail: 68px;
10451192 --nav-side: 224px;
10461193 }
10471194
@@ -1133,7 +1280,6 @@
11331280
11341281@media (max-width: 760px) {
11351282 .workspace-shell {
1136 --nav-rail: 64px;
11371283 flex-direction: column;
11381284 }
11391285 .workspace-mobile-bar {
@@ -1172,14 +1318,10 @@
11721318 opacity: 1;
11731319 visibility: visible;
11741320 }
1175 .rail-only .workspace-sidebar,
1176 .rail-only .navigation-collapsed .workspace-sidebar {
1321 .no-sidebar .workspace-mobile-bar,
1322 .no-sidebar .workspace-navigation {
11771323 display: none;
11781324 }
1179 .rail-only .workspace-navigation,
1180 .rail-only.navigation-open .workspace-navigation {
1181 width: 64px;
1182 }
11831325 .workspace-sidebar-header {
11841326 padding: 14px;
11851327 }
@@ -1190,9 +1332,6 @@
11901332 .space-community-header,
11911333 .space-community-menu-trigger {
11921334 min-height: 124px;
1193 }
1194 .workspace-rail {
1195 width: 64px;
11961335 }
11971336 .workspace-drawer-close,
11981337 .workspace-scrim {