feat(members): add members navigation and roster components with role filtering and search functionality
ZhenYi committed
Added view/app/src/domains/space/management/ui/members-nav.tsx view/app/src/domains/space/management/ui/members-nav.tsx +72 −0 View view/app/src/domains/space/management/ui/members-nav.tsx
@@ -0,0 +1,72 @@
1import { type MessageKey, type TranslateFn, useT } from '@app/i18n'
2import { IconUsers as Users } from '@tabler/icons-react'
3import type { SpaceRole } from '../model/space-admin'
4import { normalizeHexColor } from './space-settings/meta'
5
6const SYSTEM_ROLE_LABELS: Record<string, MessageKey> = {
7 owner: 'space.members.roleOwner',
8 admin: 'space.members.roleAdmin',
9 member: 'space.members.roleMember',
10}
11
12export function spaceRoleLabel(role: SpaceRole, t: TranslateFn) {
13 const key = role.system ? SYSTEM_ROLE_LABELS[role.key] : undefined
14 return key ? t(key) : role.name
15}
16
17export interface MembersNavProps {
18 roles: SpaceRole[]
19 loading?: boolean
20 activeRoleKey: string | null
21 onSelect: (roleKey: string | null) => void
22}
23
24/** Member-page sidebar: one shortcut per role, used as a roster filter. */
25export function MembersNav({ roles, loading = false, activeRoleKey, onSelect }: MembersNavProps) {
26 const { t } = useT()
27
28 return (
29 <div className="flex min-h-0 flex-1 flex-col">
30 <button
31 type="button"
32 aria-current={activeRoleKey === null ? 'page' : undefined}
33 onClick={() => onSelect(null)}
34 className="settings-nav-item"
35 >
36 <Users className="h-4 w-4 shrink-0" aria-hidden />
37 <span className="truncate">{t('space.members.all')}</span>
38 </button>
39 <p className="settings-nav-caption">{t('space.members.role')}</p>
40 <div className="min-h-0 flex-1 space-y-px overflow-y-auto">
41 {loading ? <p className="px-2 py-3 text-[13px] text-shell-ink-muted">{t('space.roles.loading')}</p> : null}
42 {!loading
43 ? roles.map((role) => {
44 const label = spaceRoleLabel(role, t)
45 const color = normalizeHexColor(role.color ?? '')
46 return (
47 <button
48 key={role.key}
49 type="button"
50 aria-current={activeRoleKey === role.key ? 'page' : undefined}
51 onClick={() => onSelect(role.key)}
52 className="settings-nav-item"
53 >
54 <span
55 aria-hidden
56 className="grid h-4 w-4 shrink-0 place-items-center rounded-full text-[10px]"
57 style={{ background: `${color}33` }}
58 >
59 {role.emoji || <span className="h-2 w-2 rounded-full" style={{ background: color }} />}
60 </span>
61 <span className="truncate">{label}</span>
62 <span className="ml-auto shrink-0 rounded-full bg-shell-field px-1.5 py-0.5 text-[11px] tabular-nums text-shell-ink-muted">
63 {role.member_count}
64 </span>
65 </button>
66 )
67 })
68 : null}
69 </div>
70 </div>
71 )
72}
Added view/app/src/domains/space/management/ui/members-roster.tsx view/app/src/domains/space/management/ui/members-roster.tsx +236 −0 View view/app/src/domains/space/management/ui/members-roster.tsx
@@ -0,0 +1,236 @@
1import { fmtDate, InitialAvatar } from '@app/domains/shared'
2import { useT } from '@app/i18n'
3import { spaceAdminMembersKey, useAsync } from '@app/lib'
4import {
5 IconArrowsUpDown as ArrowDownUp,
6 IconChevronLeft as ChevronLeft,
7 IconChevronRight as ChevronRight,
8 IconCrown as Crown,
9 IconRefresh as RefreshCw,
10 IconSearch as Search,
11} from '@tabler/icons-react'
12import { useState } from 'react'
13import { fetchSpace } from '../model/api'
14import { listSpaceMembers, type SpaceMember } from '../model/space-admin'
15import { MEMBER_STATUS_LABELS } from './space-settings/meta'
16
17const PAGE_SIZES = [25, 50, 100]
18
19export interface MembersRosterProps {
20 spaceName: string
21 /** Settings pane: the page owns the scroll container and padding. */
22 embedded?: boolean
23 /** Role key selected in the member-page sidebar. */
24 roleKey?: string | null
25 /** The selected role's localized label, used as the list heading. */
26 groupLabel?: string
27}
28
29/** Community roster: search, sort, pagination and the member table. */
30export function MembersRoster({ spaceName, embedded = false, roleKey = null, groupLabel }: MembersRosterProps) {
31 const { t } = useT()
32 const [filterKey, setFilterKey] = useState(roleKey)
33 const [tick, setTick] = useState(0)
34 const [query, setQuery] = useState('')
35 const [sort, setSort] = useState<'joined' | 'name'>('joined')
36 const [pageSize, setPageSize] = useState(PAGE_SIZES[0])
37 const [page, setPage] = useState(0)
38
39 if (filterKey !== roleKey) {
40 setFilterKey(roleKey)
41 setPage(0)
42 }
43
44 const members = useAsync(() => listSpaceMembers(spaceName), [spaceName, tick], {
45 cacheKey: spaceAdminMembersKey(spaceName),
46 })
47 const space = useAsync(() => fetchSpace(spaceName), [spaceName, tick])
48 const ownerId = space.data?.owner?.user_id ?? (space.data as unknown as { owner_uid?: string } | undefined)?.owner_uid
49
50 const all = members.data ?? []
51 const needle = query.trim().toLowerCase()
52 const filtered = all.filter((member) => {
53 if (roleKey && !member.role_keys?.includes(roleKey)) return false
54 if (!needle) return true
55 const user = member.user
56 return (
57 (user?.username ?? '').toLowerCase().includes(needle) ||
58 (user?.display_name ?? '').toLowerCase().includes(needle) ||
59 (member.alias_name ?? '').toLowerCase().includes(needle)
60 )
61 })
62 const sorted = [...filtered].sort((a, b) =>
63 sort === 'name' ? memberName(a).localeCompare(memberName(b)) : (b.joined_at ?? '').localeCompare(a.joined_at ?? ''),
64 )
65 const pageCount = Math.max(1, Math.ceil(sorted.length / pageSize))
66 const current = Math.min(page, pageCount - 1)
67 const rows = sorted.slice(current * pageSize, current * pageSize + pageSize)
68
69 return (
70 <div
71 className={
72 embedded ? 'flex flex-col gap-4' : 'flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto bg-shell-field p-6'
73 }
74 >
75 <div className="flex flex-wrap items-center gap-3">
76 <div className="min-w-0 flex-1">
77 <p className="text-[15px] font-semibold">{groupLabel || t('space.members.recent')}</p>
78 <p className="mt-0.5 text-[12.5px] text-shell-ink-muted">
79 {t('space.members.shown', { shown: rows.length, total: sorted.length })}
80 </p>
81 </div>
82 <label className="flex h-9 items-center gap-2 rounded-lg border border-shell-hairline bg-shell-field px-2.5">
83 <Search className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
84 <input
85 value={query}
86 onChange={(event) => {
87 setQuery(event.target.value)
88 setPage(0)
89 }}
90 placeholder={t('space.members.searchPlaceholder')}
91 aria-label={t('space.members.searchAria')}
92 className="w-[200px] bg-transparent text-[13px] outline-none"
93 />
94 </label>
95 <label className="flex h-9 items-center gap-2 rounded-lg border border-shell-hairline bg-shell-field px-2.5 text-[13px]">
96 <ArrowDownUp className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
97 <span className="text-shell-ink-muted">{t('space.members.sort')}</span>
98 <select
99 value={sort}
100 onChange={(event) => {
101 setSort(event.target.value as 'joined' | 'name')
102 setPage(0)
103 }}
104 aria-label={t('space.members.sort')}
105 className="bg-transparent outline-none"
106 >
107 <option value="joined">{t('space.members.sortJoined')}</option>
108 <option value="name">{t('space.members.sortName')}</option>
109 </select>
110 </label>
111 <button
112 type="button"
113 title={t('space.members.refresh')}
114 aria-label={t('space.members.refresh')}
115 onClick={() => setTick((value) => value + 1)}
116 className="workspace-icon-button is-compact"
117 >
118 <RefreshCw className="h-4 w-4" aria-hidden />
119 </button>
120 </div>
121
122 <div className="overflow-hidden rounded-xl bg-shell-surface">
123 <table className="w-full border-collapse text-[13px]">
124 <thead>
125 <tr className="text-left text-[12px] text-shell-ink-muted">
126 <th className="px-4 py-2.5 font-semibold">{t('space.members.col.name')}</th>
127 <th className="px-4 py-2.5 font-semibold">{t('space.members.col.joined')}</th>
128 <th className="px-4 py-2.5 font-semibold">{t('space.members.col.method')}</th>
129 <th className="px-4 py-2.5 font-semibold">{t('space.members.col.status')}</th>
130 </tr>
131 </thead>
132 <tbody>
133 {rows.map((member) => {
134 const name = memberName(member)
135 const isOwner = Boolean(ownerId && member.user?.user_id === ownerId)
136 return (
137 <tr
138 key={member.user?.user_id ?? member.created_at}
139 className="border-t border-shell-hairline/70 transition-colors hover:bg-shell-field/70"
140 >
141 <td className="px-4 py-2.5">
142 <span className="flex min-w-0 items-center gap-2.5">
143 <InitialAvatar name={name} url={member.alias_avatar_url || member.user?.avatar_url} size={32} />
144 <span className="min-w-0">
145 <span className="flex min-w-0 items-center gap-1.5">
146 <span className="truncate font-medium">{name}</span>
147 {isOwner ? <Crown className="h-3.5 w-3.5 shrink-0 text-[#f0b232]" aria-hidden /> : null}
148 </span>
149 <span className="block truncate text-[12px] text-shell-ink-muted">
150 @{member.user?.username ?? member.user?.user_id}
151 </span>
152 </span>
153 </span>
154 </td>
155 <td className="px-4 py-2.5 text-shell-ink-muted">{fmtDate(member.joined_at)}</td>
156 <td className="px-4 py-2.5 text-shell-ink-muted">
157 {member.invited_by
158 ? t('space.members.invitedBy', {
159 name: member.invited_by.display_name || member.invited_by.username,
160 })
161 : isOwner
162 ? t('space.members.owner')
163 : t('space.members.joinedSelf')}
164 </td>
165 <td className="px-4 py-2.5">
166 <span className="rounded-full bg-shell-field px-2 py-0.5 text-[11.5px] text-shell-ink-muted">
167 {MEMBER_STATUS_LABELS[member.status] ?? member.status}
168 </span>
169 </td>
170 </tr>
171 )
172 })}
173 {rows.length === 0 ? (
174 <tr>
175 <td colSpan={4} className="px-4 py-12 text-center text-[13px] text-shell-ink-muted">
176 {t('space.members.noMatch')}
177 </td>
178 </tr>
179 ) : null}
180 </tbody>
181 </table>
182 </div>
183
184 <div className="flex flex-wrap items-center justify-between gap-3">
185 <label className="flex items-center gap-2 text-[12.5px] text-shell-ink-muted">
186 {t('space.members.rowsPerPage')}
187 <select
188 value={pageSize}
189 onChange={(event) => {
190 setPageSize(Number(event.target.value))
191 setPage(0)
192 }}
193 className="h-8 rounded-lg border border-shell-hairline bg-shell-field px-2 text-[13px] text-shell-ink"
194 >
195 {PAGE_SIZES.map((size) => (
196 <option key={size} value={size}>
197 {size}
198 </option>
199 ))}
200 </select>
201 </label>
202 {pageCount > 1 ? (
203 <div className="flex items-center gap-2 text-[12.5px] text-shell-ink-muted">
204 <button
205 type="button"
206 aria-label="previous"
207 disabled={current === 0}
208 onClick={() => setPage(Math.max(0, current - 1))}
209 className="workspace-icon-button is-compact"
210 >
211 <ChevronLeft className="h-4 w-4" aria-hidden />
212 </button>
213 <span>
214 {current + 1} / {pageCount}
215 </span>
216 <button
217 type="button"
218 aria-label="next"
219 disabled={current >= pageCount - 1}
220 onClick={() => setPage(Math.min(pageCount - 1, current + 1))}
221 className="workspace-icon-button is-compact"
222 >
223 <ChevronRight className="h-4 w-4" aria-hidden />
224 </button>
225 </div>
226 ) : null}
227 </div>
228 </div>
229 )
230}
231
232function memberName(member: SpaceMember): string {
233 return (
234 member.alias_name || member.user?.display_name || member.user?.username || String(member.user?.user_id ?? '') || '—'
235 )
236}
Added view/index/src/assets/manrope-latin.woff2 view/index/src/assets/manrope-latin.woff2 +0 −0 View view/index/src/assets/manrope-latin.woff2
Binary file not shown.