feat(space): Discord-style community header in the sidebar
The space identity block was a plain row of text above the channel list. It now reads as a community banner: the uploaded banner image when a space has one, otherwise a gradient derived from a stable hue, with the icon, name and member count kept legible over either and the whole block acting as the section menu trigger. The drawer close button floats over the banner instead of pushing it around. The banner fetches its own single-kind key (new spaceImageKey, deliberately distinct from the settings sections all-kind record so neither consumer is served the other shape), and uploading or deleting an image invalidates it so the header swaps on the next frame rather than showing the old picture until a reload.
ZhenYi committed
Modified web/src/app/styles/workspace.css web/src/app/styles/workspace.css +31 −8 View web/src/app/styles/workspace.css
@@ -24,6 +24,26 @@
2424.workspace-sidebar { display: flex; flex-direction: column; width: var(--nav-side); border-right: 1px solid var(--shell-hairline); background: var(--shell-sidebar); transition: opacity calc(var(--nav-motion) * .45) ease calc(var(--nav-motion) * .4), visibility 0s; }
2525.workspace-sidebar-header { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 76px; padding: 14px 20px; border-bottom: 1px solid var(--shell-hairline); }
2626.workspace-sidebar-header > div { min-width: 0; }
27/* The community header borrows Discord's compact-cover treatment: a real
28 * uploaded banner when available, a stable colour cover otherwise, and the
29 * community identity kept legible over both. */
30.workspace-sidebar-header:has(.space-community-header) { position: relative; align-items: stretch; min-height: 132px; padding: 0; gap: 0; }
31.workspace-sidebar-header:has(.space-community-header) > .space-community-header { width: 100%; }
32.workspace-sidebar-header:has(.space-community-header) .workspace-drawer-close { position: absolute; z-index: 42; top: 10px; right: 10px; color: #fff; background: rgb(10 14 24 / 28%); backdrop-filter: blur(8px); }
33.workspace-sidebar-header:has(.space-community-header) .workspace-drawer-close:hover { color: #fff; background: rgb(10 14 24 / 50%); }
34.space-community-header { position: relative; z-index: 1; min-width: 0; min-height: 132px; isolation: isolate; }
35.space-community-banner { position: absolute; inset: 0; z-index: -1; overflow: hidden; background: radial-gradient(132px 88px at 8% -14%, hsl(var(--space-banner-hue) 94% 82% / 88%), transparent 72%), radial-gradient(142px 112px at 104% 2%, hsl(calc(var(--space-banner-hue) + 46) 78% 58% / 90%), transparent 72%), linear-gradient(145deg, hsl(var(--space-banner-hue) 62% 42%), hsl(calc(var(--space-banner-hue) + 26) 70% 27%)); }
36.space-community-banner::before, .space-community-banner::after { position: absolute; inset: 0; content: ''; pointer-events: none; }
37.space-community-banner::before { background: linear-gradient(116deg, rgb(255 255 255 / 14%) 0 12%, transparent 12% 42%, rgb(255 255 255 / 9%) 42% 52%, transparent 52%); mix-blend-mode: soft-light; }
38.space-community-banner::after { background: linear-gradient(180deg, rgb(8 12 20 / 4%) 0%, rgb(8 12 20 / 15%) 44%, rgb(8 12 20 / 74%) 100%); }
39.space-community-banner > img { width: 100%; height: 100%; object-fit: cover; transform: scale(1.02); }
40.space-community-menu-trigger { position: relative; z-index: 1; display: flex; width: 100%; min-height: 132px; align-items: flex-end; gap: 10px; padding: 15px 16px; color: #fff; text-align: left; text-shadow: 0 1px 2px rgb(0 0 0 / 40%); transition: background 160ms; }
41.space-community-menu-trigger:hover, .space-community-menu-trigger[aria-expanded="true"] { background: rgb(7 10 16 / 15%); }
42.space-community-avatar { display: grid; width: 42px; height: 42px; flex: 0 0 auto; place-items: center; overflow: hidden; border: 2px solid rgb(255 255 255 / 74%); border-radius: 13px; background: rgb(255 255 255 / 18%); box-shadow: 0 3px 12px rgb(0 0 0 / 28%); font-size: 17px; font-weight: 700; text-shadow: none; }
43.space-community-avatar > img { width: 100%; height: 100%; object-fit: cover; }
44.space-community-name { display: block; overflow: hidden; color: #fff; font-size: 15px; font-weight: 700; line-height: 1.2; text-overflow: ellipsis; white-space: nowrap; }
45.space-community-members { display: inline-flex; align-items: center; min-height: 18px; margin-top: 4px; padding: 1px 6px; border: 1px solid rgb(255 255 255 / 20%); border-radius: 999px; background: rgb(255 255 255 / 16%); color: rgb(255 255 255 / 92%); font-size: 10px; font-weight: 700; line-height: 1; text-shadow: none; }
46.space-community-menu-trigger > svg { color: rgb(255 255 255 / 88%); }
2747.workspace-nav-caption { display: flex; align-items: center; justify-content: space-between; padding: 18px 16px 8px 20px; color: var(--shell-ink-muted); font-size: 12px; font-weight: 600; letter-spacing: .06em; }
2848.workspace-nav-pinned { flex-shrink: 0; padding: 10px 12px 2px; }
2949.workspace-nav { flex: 1; overflow-y: auto; padding: 0 12px 20px; }
@@ -69,18 +89,19 @@
6989}
7090/*
7191 * An open chat panel owns the same top-right corner, so the overlay steps left
72 * by the panel’s width (320px, 380px from `xl`) — otherwise it covers the
73 * panel’s own close button. Below 1201px the panel is a modal drawer behind a
74 * scrim, and the trigger hides with the page it covers.
92 * by the panel's live width — otherwise it lands inside a resized detail pane.
93 * Below 1201px the panel is a modal drawer behind a scrim, and the trigger
94 * hides with the page it covers.
7595 */
7696@media (max-width: 1200px) {
7797 body:has(.chat-side-panel) .global-search-trigger { display: none; }
7898}
7999@media (min-width: 1201px) {
80 body:has(.chat-side-panel) .global-search-trigger { right: 336px; }
81}
82@media (min-width: 1280px) {
83 body:has(.chat-side-panel) .global-search-trigger { right: 396px; }
100 body:has(.chat-side-panel) .global-search-trigger { right: calc(var(--global-chat-panel-width, 380px) + 16px); }
101 /* A custom-width detail pane becomes the reading focus. Hide the global
102 * command until that pane closes, and release its former header gutter. */
103 body:has(.chat-panel-resized) .global-search-trigger { display: none; }
104 body:has(.chat-panel-resized) .workspace-main .chat-header { padding-right: 24px; }
84105}
85106.workspace-mobile-bar, .workspace-drawer-close, .workspace-scrim { display: none; }
86107.workspace-skip { position: fixed; left: 12px; top: -80px; z-index: 100; padding: 10px 16px; border-radius: 10px; color: white; background: var(--shell-action); }
@@ -215,6 +236,8 @@
215236 .rail-only .workspace-sidebar, .rail-only .navigation-collapsed .workspace-sidebar { display: none; }
216237 .rail-only .workspace-navigation, .rail-only.navigation-open .workspace-navigation { width: 64px; }
217238 .workspace-sidebar-header { padding: 14px; }
239 .workspace-sidebar-header:has(.space-community-header) { min-height: 124px; padding: 0; }
240 .space-community-header, .space-community-menu-trigger { min-height: 124px; }
218241 .workspace-rail { width: 64px; }
219242 .workspace-drawer-close, .workspace-scrim { display: flex; }
220243 .workspace-collapse, .workspace-expand { display: none; }
@@ -302,4 +325,4 @@
302325.gitdata-markdown td { border: 1px solid var(--shell-hairline); padding: 5px 9px; }
303326.gitdata-markdown th { background: var(--shell-field); font-weight: 600; }
304327.gitdata-markdown img { max-width: 100%; border-radius: 6px; }
305.gitdata-markdown hr { border: none; border-top: 1px solid var(--shell-hairline); margin: 1em 0; }
No newline at end of file
328.gitdata-markdown hr { border: none; border-top: 1px solid var(--shell-hairline); margin: 1em 0; }
Modified web/src/features/space-management/ui/space-settings/images-section.tsx web/src/features/space-management/ui/space-settings/images-section.tsx +6 −1 View web/src/features/space-management/ui/space-settings/images-section.tsx
@@ -7,7 +7,8 @@
77 fmtDate,
88 useSave,
99} from '@/shared/ui'
10import { spaceAdminKey, useAsync } from '@/shared/lib'
10import { spaceAdminKey, spaceImageKey, useAsync } from '@/shared/lib'
11import { invalidateCache } from '@/shared/storage'
1112import { useT } from '@/shared/i18n'
1213import { deleteSpaceImage, getSpaceImage, uploadSpaceImage, type SpaceImage } from '../../model/space-admin'
1314import { IMAGE_KINDS } from './meta'
@@ -84,6 +85,9 @@
8485 if (file.size > MAX_BYTES) throw new Error(t('space.images.tooLarge'))
8586 const size = await readImageSize(file)
8687 await uploadSpaceImage(spaceName, kind, file, size)
88 // The sidebar banner reads its own single-kind key; drop it so the
89 // header picks up the new image on its next fetch.
90 invalidateCache(spaceImageKey(spaceName, kind))
8791 onSaved()
8892 // The header avatar and the rail tile read the space row, not this call.
8993 onSpaceUpdated()
@@ -145,6 +149,7 @@
145149 onClick={() =>
146150 void run(async () => {
147151 await deleteSpaceImage(spaceName, kind)
152 invalidateCache(spaceImageKey(spaceName, kind))
148153 onSaved()
149154 onSpaceUpdated()
150155 }, t('space.images.deleted', { label }))
Modified web/src/features/space-management/ui/space-header.tsx web/src/features/space-management/ui/space-header.tsx +51 −9 View web/src/features/space-management/ui/space-header.tsx
@@ -1,4 +1,4 @@
1import { useEffect, useRef, useState, type ReactNode } from 'react'
1import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'
22import {
33 ChevronDown,
44 Download,
@@ -25,8 +25,9 @@
2525} from '../model/api'
2626import type { MySpace } from '../model/api'
2727import { ImportRepoModal } from './import-repo-modal'
28import { findUserByUsername, listSpaceGroups } from '../model/space-admin'
28import { findUserByUsername, getSpaceImage, listSpaceGroups } from '../model/space-admin'
2929import { FormModal, parseList } from '@/shared/ui'
30import { spaceImageKey, useAsync } from '@/shared/lib'
3031import { t as tr, useT } from '@/shared/i18n'
3132import { MANAGE_PERMS, parentDirOptions } from './space-settings/meta'
3233
@@ -75,6 +76,7 @@
7576 const [dialog, setDialog] = useState<Dialog>(null)
7677 const [dirOptions, setDirOptions] = useState<{ value: string; label: string }[]>([])
7778 const [groupOptions, setGroupOptions] = useState<{ value: string; label: string }[]>([])
79 const [failedBannerUrl, setFailedBannerUrl] = useState<string | null>(null)
7880 const rootRef = useRef<HTMLDivElement>(null)
7981
8082 const perms = new Set(permissions)
@@ -84,6 +86,19 @@
8486 // /me/spaces ships `owner_uid`; the full SpaceDto embeds `owner`.
8587 const ownerUid = space.owner?.user_id ?? (space as unknown as { owner_uid?: string }).owner_uid
8688 const isOwner = Boolean(ownerUid && viewerId && ownerUid === viewerId)
89 // The image endpoint already enforces visibility for private communities and
90 // provides a presigned URL, so the sidebar can use the same uploaded banner
91 // as the community settings and directory views.
92 // Cached: switching between a space's chat, board and repo rebuilds the
93 // sidebar (each section renders its own layout), and an uncached fetch made
94 // that rebuild visible — the gradient showed for a frame and the banner
95 // popped in when the request landed. The local-first tier paints the known
96 // banner synchronously and still revalidates in the background.
97 const banner = useAsync(() => getSpaceImage(space.name, 'banner'), [space.name], {
98 cacheKey: spaceImageKey(space.name, 'banner'),
99 })
100 const bannerUrl = banner.data?.url
101 const showBannerImage = Boolean(bannerUrl && bannerUrl !== failedBannerUrl)
87102
88103 useEffect(() => {
89104 if (!menuOpen) return
@@ -155,20 +170,40 @@
155170 }, [dialog, space.name])
156171
157172 return (
158 <div ref={rootRef} className="relative min-w-0 flex-1">
173 <div
174 ref={rootRef}
175 className="space-community-header relative min-w-0 flex-1"
176 style={{ '--space-banner-hue': String(bannerHue(space.name)) } as CSSProperties}
177 >
178 <div className={`space-community-banner${showBannerImage ? ' has-image' : ''}`} aria-hidden="true">
179 {showBannerImage ? (
180 <img
181 src={bannerUrl}
182 alt=""
183 onError={() => bannerUrl && setFailedBannerUrl(bannerUrl)}
184 />
185 ) : null}
186 </div>
159187 <button
160188 type="button"
161189 aria-haspopup="menu"
162190 aria-expanded={menuOpen}
163191 onClick={() => setMenuOpen(open => !open)}
164 className="flex w-full items-center gap-2 rounded-md px-1.5 py-1 text-left transition-colors hover:bg-shell-active"
192 className="space-community-menu-trigger"
165193 >
166 <span className="truncate text-[15px] font-semibold">{space.name}</span>
167 <span className="shrink-0 rounded-full bg-shell-active px-1.5 py-0.5 text-[10px] font-semibold text-shell-ink-muted">
168 {space.members_count}
194 <span className="space-community-avatar" aria-hidden="true">
195 {space.avatar_url ? (
196 <img src={space.avatar_url} alt="" />
197 ) : (
198 [...space.name][0]?.toUpperCase() ?? '?'
199 )}
200 </span>
201 <span className="min-w-0 flex-1">
202 <span className="space-community-name">{space.name}</span>
203 <span className="space-community-members">{space.members_count}</span>
169204 </span>
170205 <ChevronDown
171 className={`ml-auto h-4 w-4 shrink-0 text-shell-ink-muted transition-transform ${
206 className={`h-4 w-4 shrink-0 transition-transform ${
172207 menuOpen ? 'rotate-180' : ''
173208 }`}
174209 aria-hidden
@@ -178,7 +213,7 @@
178213 {menuOpen ? (
179214 <div
180215 role="menu"
181 className="absolute left-0 top-full z-40 mt-1 w-56 rounded-lg border-2 border-shell-hairline bg-shell-surface p-1.5 shadow-xl"
216 className="absolute left-3 right-3 top-full z-40 mt-2 rounded-lg border-2 border-shell-hairline bg-shell-surface p-1.5 shadow-xl"
182217 >
183218 {perms.has('manage_invites') ? (
184219 <MenuItem icon={UserPlus} label={t('space.menu.invite')} onClick={() => openDialog('invite')} />
@@ -521,6 +556,13 @@
521556 )
522557}
523558
559/** Stable fallback colour so communities without an uploaded banner stay recognisable. */
560function bannerHue(name: string): number {
561 let hash = 0
562 for (const character of name) hash = (hash * 31 + (character.codePointAt(0) ?? 0)) % 360
563 return hash
564}
565
524566type CreateRoomKind = 'Text' | 'Voice' | 'Stage'
525567type AudienceValue = 'Everyone' | 'FollowingOnly' | 'Nobody'
526568
Modified web/src/shared/lib/cache-keys.ts web/src/shared/lib/cache-keys.ts +10 −0 View web/src/shared/lib/cache-keys.ts
@@ -42,6 +42,16 @@
4242 */
4343export const spaceAdminKey = (space: string, part: string) => `space:${space}:admin:${part}`
4444
45/**
46 * One uploaded space image (`banner`, `icon`, …) as a single `SpaceImage` row.
47 *
48 * Distinct from `spaceAdminKey(space, 'images')`, which caches the *record of
49 * all kinds* the settings section renders: sharing a key would serve one
50 * consumer the other's shape. The sidebar reads this key so a community banner
51 * is on screen from the first frame instead of arriving after a refetch.
52 */
53export const spaceImageKey = (space: string, kind: string) => `space:${space}:image:${kind}`
54
4555/** Whole-space invalidation prefix (note the trailing separator). */
4656export const spacePrefix = (space: string) => `space:${space}:`
4757