feat(web/app): add the space domain
Port space navigation, discovery and management. Space creation now sends the organization body (slug plus join policy) and invites are redeemed with the token endpoint, matching the current contract.
ZhenYi committed
Added view/app/src/domains/space/discovery/model/api.ts view/app/src/domains/space/discovery/model/api.ts +93 −0 View view/app/src/domains/space/discovery/model/api.ts
@@ -0,0 +1,93 @@
1/**
2 * Explore API: the public space directory plus the applicant side of the
3 * join-request flow (submit / withdraw). The approval side lives in
4 * features/space-management.
5 *
6 * The generated client collapses `Page<ExploreSpaceDto>` into one untyped
7 * `Page` schema (same as every other paged space list), and the flattened
8 * document keeps `ExploreSpaceDto` out of `globals.d`, so the row shape is
9 * declared here instead.
10 */
11
12import { type ApiEnvelope, unwrapApi } from '@app/lib'
13import type {
14 AllowJoinLevel,
15 CreateJoinRequestCmd,
16 EnvelopeJoinRequestDto,
17 SpaceJoinRequestStatus,
18} from '@app/services/api'
19import Apis from '@app/services/api'
20
21export type { AllowJoinLevel, CreateJoinRequestCmd, SpaceJoinRequestStatus }
22
23/** One viewer-scoped request row, as the directory embeds it. */
24export interface ExploreViewerRequest {
25 id: string
26 status: SpaceJoinRequestStatus
27 created_at: string
28 decided_at?: string | null
29 cooldown_until?: string | null
30}
31
32export interface ExploreViewer {
33 is_member: boolean
34 request?: ExploreViewerRequest | null
35}
36
37/** One public space card row (`GET /spaces/explore`). */
38export interface ExploreSpace {
39 id: string
40 lower_name: string
41 name: string
42 description?: string | null
43 avatar_url?: string | null
44 banner_url?: string | null
45 members_count: number
46 allow_join: boolean
47 allow_join_level: AllowJoinLevel
48 created_at: string
49 viewer?: ExploreViewer | null
50}
51
52export interface ExplorePage {
53 items: ExploreSpace[]
54 total: number
55 limit: number
56 offset: number
57}
58
59export type JoinRequest = NonNullable<EnvelopeJoinRequestDto['data']>
60
61export interface ExploreQuery {
62 q?: string
63 sort?: 'members' | 'newest'
64 limit?: number
65 offset?: number
66}
67
68/**
69 * Unset filters are omitted rather than sent as `null`: Alova serialises a
70 * `null` query value to the literal string `"null"`, which the backend's
71 * `Option<...>` parsers reject with a 400.
72 */
73export const exploreSpaces = (query: ExploreQuery) =>
74 unwrapApi<ExplorePage | null>(
75 Apis.space.explore_spaces({
76 params: {
77 ...(query.q ? { q: query.q } : {}),
78 ...(query.sort ? { sort: query.sort } : {}),
79 ...(query.limit === undefined ? {} : { limit: query.limit }),
80 ...(query.offset === undefined ? {} : { offset: query.offset }),
81 },
82 }) as unknown as PromiseLike<ApiEnvelope<ExplorePage | null>>,
83 ).then((page) => page ?? { items: [], total: 0, limit: 0, offset: 0 })
84
85export const submitJoinRequest = (spaceName: string, cmd: CreateJoinRequestCmd) =>
86 unwrapApi<JoinRequest>(Apis.space.create_join_request({ pathParams: { space_name: spaceName }, data: cmd }))
87
88export const withdrawJoinRequest = (spaceName: string, requestId: string) =>
89 unwrapApi<null>(
90 Apis.space.withdraw_join_request({
91 pathParams: { space_name: spaceName, request_id: requestId },
92 }),
93 )
Added view/app/src/domains/space/discovery/ui/explore-view.tsx view/app/src/domains/space/discovery/ui/explore-view.tsx +517 −0 View view/app/src/domains/space/discovery/ui/explore-view.tsx
@@ -0,0 +1,517 @@
1import { ShellTopBar } from '@app/blocks'
2import { Alert, Modal } from '@app/components'
3import { EmptyNote, errText, fmtDate } from '@app/domains/shared'
4import { type MessageKey, useT } from '@app/i18n'
5import { ApiFailure, useAsync } from '@app/lib'
6import { Button } from '@app/ui'
7import { Compass, Loader2, Search, Users } from 'lucide-react'
8import { useEffect, useState } from 'react'
9import { useNavigate } from 'react-router-dom'
10import {
11 type AllowJoinLevel,
12 type ExploreSpace,
13 exploreSpaces,
14 submitJoinRequest,
15 withdrawJoinRequest,
16} from '../model/api'
17
18/** Card-header gradients for spaces without a banner; the index is stable per id. */
19const BANNER_GRADIENTS = [
20 'linear-gradient(135deg, #4f7d3a 0%, #2f4f2a 100%)',
21 'linear-gradient(135deg, #3a6b8f 0%, #233f57 100%)',
22 'linear-gradient(135deg, #8f5a3a 0%, #573323 100%)',
23 'linear-gradient(135deg, #6b3a8f 0%, #3f2357 100%)',
24 'linear-gradient(135deg, #8f3a5a 0%, #572337 100%)',
25 'linear-gradient(135deg, #3a8f7a 0%, #235749 100%)',
26 'linear-gradient(135deg, #7a8f3a 0%, #4a5723 100%)',
27 'linear-gradient(135deg, #5a5f8f 0%, #333657 100%)',
28]
29
30/** Re-render clock for time-derived affordances (join-request cooldown). */
31function useNow(intervalMs = 30_000): number {
32 const [now, setNow] = useState(0)
33 useEffect(() => {
34 const tick = () => setNow(Date.now())
35 tick()
36 const timer = window.setInterval(tick, intervalMs)
37 return () => window.clearInterval(timer)
38 }, [intervalMs])
39 return now
40}
41function bannerGradient(id: string): string {
42 let hash = 0
43 for (const char of id) hash = (hash * 31 + char.charCodeAt(0)) | 0
44 return BANNER_GRADIENTS[Math.abs(hash) % BANNER_GRADIENTS.length]
45}
46
47const JOIN_LEVEL_KEYS: Record<AllowJoinLevel, MessageKey> = {
48 None: 'space.discover.joinLevel.none',
49 NeedMailActive: 'space.joinLevel.mail',
50 NeedMailActiveAndLastOneMid: 'space.joinLevel.mailDay',
51 NeedMailActiveAndLastOneHours: 'space.joinLevel.mailHour',
52 NeedMailActiveAndPhoneActive: 'space.joinLevel.mailPhone',
53}
54
55const PAGE_SIZE = 24
56
57/** Card density for the directory grid: three steps, remembered per browser. */
58type CardSize = 'lg' | 'md' | 'sm'
59
60const CARD_SIZE_KEY = 'gitdata.explore.cardSize'
61
62interface CardLayout {
63 /** Grid columns per size; literal strings so the CSS build keeps every class. */
64 grid: string
65 banner: string
66 avatar: string
67 avatarAnchor: string
68 body: string
69 name: string
70 meta: string
71 desc: string
72 actionButton: string
73 actionText: string
74}
75
76const CARD_LAYOUTS: Record<CardSize, CardLayout> = {
77 lg: {
78 grid: 'grid-cols-1 gap-5 lg:grid-cols-2',
79 banner: 'aspect-[16/6]',
80 avatar: 'h-14 w-14 text-[18px]',
81 avatarAnchor: '-bottom-6 left-5',
82 body: 'px-5 pb-5 pt-8',
83 name: 'text-[16px]',
84 meta: 'text-[12px]',
85 desc: 'mt-1 line-clamp-3 min-h-[3.9em] text-[13px] leading-[1.3] text-shell-ink-muted',
86 actionButton: 'h-9 text-[13px]',
87 actionText: 'text-[13px]',
88 },
89 md: {
90 grid: 'grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3',
91 banner: 'aspect-[16/7]',
92 avatar: 'h-12 w-12 text-[16px]',
93 avatarAnchor: '-bottom-5 left-4',
94 body: 'px-4 pb-4 pt-7',
95 name: 'text-[14.5px]',
96 meta: 'text-[11.5px]',
97 desc: 'mt-1 line-clamp-2 min-h-[2.6em] text-[12.5px] leading-[1.3] text-shell-ink-muted',
98 actionButton: 'h-8 text-[12.5px]',
99 actionText: 'text-[12.5px]',
100 },
101 sm: {
102 grid: 'grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5',
103 banner: 'aspect-[16/5]',
104 avatar: 'h-9 w-9 text-[12.5px]',
105 avatarAnchor: '-bottom-4 left-3',
106 body: 'px-3 pb-3 pt-6',
107 name: 'text-[13px]',
108 meta: 'text-[10.5px]',
109 desc: 'mt-1 line-clamp-1 min-h-[1.3em] text-[11.5px] leading-[1.3] text-shell-ink-muted',
110 actionButton: 'h-7 text-[11.5px]',
111 actionText: 'text-[11.5px]',
112 },
113}
114
115const CARD_SIZE_KEYS: Record<CardSize, MessageKey> = {
116 lg: 'space.discover.cardSize.large',
117 md: 'space.discover.cardSize.medium',
118 sm: 'space.discover.cardSize.small',
119}
120
121function readCardSize(): CardSize {
122 try {
123 const raw = localStorage.getItem(CARD_SIZE_KEY)
124 if (raw === 'lg' || raw === 'md' || raw === 'sm') return raw
125 } catch {
126 // Storage may be unavailable; the default size still works.
127 }
128 return 'md'
129}
130export interface ExploreViewProps {
131 /** Opens the create-space modal owned by the shell. */
132 onAddSpace: () => void
133}
134
135/**
136 * The public directory (`/app/explore`). It is deliberately outside the
137 * space-scoped shell data: the rows come from one public endpoint and every
138 * card either walks into a space or files a join request, so no sidebar list
139 * or room prefetch applies.
140 */
141export function ExploreView({ onAddSpace }: ExploreViewProps) {
142 const { t } = useT()
143 const navigate = useNavigate()
144 const [query, setQuery] = useState('')
145 const [debounced, setDebounced] = useState('')
146 const [sort, setSort] = useState<'members' | 'newest'>('members')
147 const [cardSize, setCardSize] = useState<CardSize>(readCardSize)
148 const [limit, setLimit] = useState(PAGE_SIZE)
149 const [reload, setReload] = useState(0)
150 const [busyId, setBusyId] = useState<string | null>(null)
151 const [actionError, setActionError] = useState('')
152 const [applying, setApplying] = useState<ExploreSpace | null>(null)
153 const [note, setNote] = useState('')
154 const [submitting, setSubmitting] = useState(false)
155
156 // Search is debounced so a typing burst issues one request, and a new query
157 // restarts pagination: the previous page count belongs to the previous list.
158 useEffect(() => {
159 const timer = window.setTimeout(() => {
160 setDebounced(query.trim())
161 setLimit(PAGE_SIZE)
162 }, 250)
163 return () => window.clearTimeout(timer)
164 }, [query])
165
166 const page = useAsync(
167 () => exploreSpaces({ q: debounced || undefined, sort, limit, offset: 0 }),
168 [debounced, sort, limit, reload],
169 )
170 const rows = page.data?.items ?? []
171 const total = page.data?.total ?? 0
172 const layout = CARD_LAYOUTS[cardSize]
173
174 const changeCardSize = (next: CardSize) => {
175 setCardSize(next)
176 try {
177 localStorage.setItem(CARD_SIZE_KEY, next)
178 } catch {
179 // The preference is a convenience; failing to store it must not throw.
180 }
181 }
182
183 const openSpace = (space: ExploreSpace) => {
184 navigate(`/app/${encodeURIComponent(space.name)}`)
185 }
186
187 const withdraw = async (space: ExploreSpace) => {
188 const request = space.viewer?.request
189 if (!request) return
190 setBusyId(space.id)
191 setActionError('')
192 try {
193 await withdrawJoinRequest(space.name, request.id)
194 setReload((key) => key + 1)
195 } catch (error) {
196 setActionError(errText(error))
197 } finally {
198 setBusyId(null)
199 }
200 }
201
202 const submit = async () => {
203 if (!applying || submitting) return
204 setSubmitting(true)
205 setActionError('')
206 try {
207 await submitJoinRequest(applying.name, { note: note.trim() || null })
208 setApplying(null)
209 setNote('')
210 setReload((key) => key + 1)
211 } catch (error) {
212 // The stable codes distinguish "wait" from "not eligible" from
213 // "closed"; anything else is surfaced verbatim.
214 const message =
215 error instanceof ApiFailure && error.errorCode === 'join_request_cooldown'
216 ? t('space.discover.cooldownError')
217 : error instanceof ApiFailure && error.errorCode === 'join_requirement_not_met'
218 ? t('space.discover.requirementError')
219 : error instanceof ApiFailure && error.errorCode === 'join_not_allowed'
220 ? t('space.discover.notAllowedError')
221 : errText(error)
222 setActionError(message)
223 } finally {
224 setSubmitting(false)
225 }
226 }
227
228 return (
229 <>
230 <ShellTopBar
231 icon={<Compass className="h-[18px] w-[18px] shrink-0 text-shell-ink-muted" />}
232 title={t('space.discover.title')}
233 topic={t('space.discover.sub')}
234 />
235 <div className="flex min-h-0 flex-1 flex-col">
236 <div className="flex flex-wrap items-center gap-3 border-b border-shell-hairline px-6 py-3">
237 <div className="relative min-w-[220px] flex-1 md:max-w-[420px]">
238 <Search
239 className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-shell-ink-muted"
240 aria-hidden
241 />
242 <input
243 value={query}
244 onChange={(event) => setQuery(event.target.value)}
245 placeholder={t('space.discover.search')}
246 aria-label={t('space.discover.search')}
247 className="h-10 w-full rounded-field border border-shell-hairline bg-shell-field pl-9 pr-3 text-[13.5px] outline-none placeholder:text-shell-ink-muted focus:border-shell-ink"
248 />
249 </div>
250 <div
251 className="flex items-center gap-1 rounded-field bg-shell-field p-1"
252 role="group"
253 aria-label={t('space.discover.title')}
254 >
255 {(['members', 'newest'] as const).map((value) => (
256 <button
257 key={value}
258 type="button"
259 aria-pressed={sort === value}
260 onClick={() => {
261 setSort(value)
262 setLimit(PAGE_SIZE)
263 }}
264 className={`rounded-[8px] px-3 py-1.5 text-[12.5px] transition-colors ${
265 sort === value
266 ? 'bg-shell-surface font-semibold shadow-sm'
267 : 'text-shell-ink-muted hover:text-shell-ink'
268 }`}
269 >
270 {t(value === 'members' ? 'space.discover.sort.members' : 'space.discover.sort.newest')}
271 </button>
272 ))}
273 </div>
274 <span className="text-[12.5px] text-shell-ink-muted">
275 {t('space.discover.resultCount', { count: total })}
276 </span>
277 <div className="ml-auto flex items-center gap-2">
278 <div
279 className="flex items-center gap-1 rounded-field bg-shell-field p-1"
280 role="group"
281 aria-label={t('space.discover.cardSize')}
282 >
283 {(['lg', 'md', 'sm'] as const).map((value) => (
284 <button
285 key={value}
286 type="button"
287 aria-pressed={cardSize === value}
288 title={t(CARD_SIZE_KEYS[value])}
289 onClick={() => changeCardSize(value)}
290 className={`rounded-[8px] px-2.5 py-1.5 text-[12.5px] transition-colors ${
291 cardSize === value
292 ? 'bg-shell-surface font-semibold shadow-sm'
293 : 'text-shell-ink-muted hover:text-shell-ink'
294 }`}
295 >
296 {t(CARD_SIZE_KEYS[value])}
297 </button>
298 ))}
299 </div>
300 <Button variant="secondary" className="h-9 text-[12.5px]" onClick={onAddSpace}>
301 {t('shell.rail.addSpace')}
302 </Button>
303 </div>
304 </div>
305
306 {actionError ? (
307 <div className="px-6 pt-4">
308 <Alert tone="error">{actionError}</Alert>
309 </div>
310 ) : null}
311
312 <div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
313 {page.loading && !page.data ? (
314 <div className="flex items-center justify-center gap-2 py-16 text-[13px] text-shell-ink-muted">
315 <Loader2 className="h-4 w-4 animate-spin" aria-hidden />
316 {t('space.discover.loading')}
317 </div>
318 ) : page.error && !page.data ? (
319 <div className="py-16 text-center">
320 <EmptyNote>{t('space.discover.loadFailed')}</EmptyNote>
321 <Button
322 variant="secondary"
323 className="mt-3 h-9 text-[12.5px]"
324 onClick={() => setReload((key) => key + 1)}
325 >
326 {t('common.action.retry')}
327 </Button>
328 </div>
329 ) : rows.length === 0 ? (
330 <div className="py-16 text-center">
331 <EmptyNote>{t('space.discover.empty')}</EmptyNote>
332 <p className="mt-1 text-[12.5px] text-shell-ink-muted">{t('space.discover.emptyHint')}</p>
333 </div>
334 ) : (
335 <>
336 <ul className={`grid ${layout.grid}`}>
337 {rows.map((space) => (
338 <SpaceCard
339 key={space.id}
340 space={space}
341 layout={layout}
342 busy={busyId === space.id}
343 onOpen={() => openSpace(space)}
344 onApply={() => {
345 setActionError('')
346 setNote('')
347 setApplying(space)
348 }}
349 onWithdraw={() => void withdraw(space)}
350 />
351 ))}
352 </ul>
353 {rows.length < total ? (
354 <div className="mt-6 flex justify-center">
355 <Button
356 variant="secondary"
357 className="h-9 text-[12.5px]"
358 disabled={page.loading}
359 onClick={() => setLimit((current) => current + PAGE_SIZE)}
360 >
361 {page.loading ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> : null}
362 {t('space.discover.loadMore')}
363 </Button>
364 </div>
365 ) : null}
366 </>
367 )}
368 </div>
369 </div>
370
371 <Modal
372 title={applying ? t('space.discover.dialogTitle', { space: applying.name }) : ''}
373 description={t('space.discover.noteHint')}
374 open={Boolean(applying)}
375 onClose={() => {
376 if (submitting) return
377 setApplying(null)
378 }}
379 width="max-w-[520px]"
380 footer={
381 <>
382 <Button
383 variant="ghost"
384 className="h-9 text-[12.5px]"
385 disabled={submitting}
386 onClick={() => setApplying(null)}
387 >
388 {t('common.action.cancel')}
389 </Button>
390 <Button className="h-9 text-[12.5px]" disabled={submitting} onClick={() => void submit()}>
391 {submitting ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> : null}
392 {t('space.discover.submit')}
393 </Button>
394 </>
395 }
396 >
397 <label className="block">
398 <span className="mb-1.5 block font-mono text-[10px] font-bold uppercase tracking-[1.2px] text-shell-ink-muted">
399 {t('space.discover.note')}
400 </span>
401 <textarea
402 value={note}
403 maxLength={512}
404 rows={4}
405 placeholder={t('space.discover.notePlaceholder')}
406 onChange={(event) => setNote(event.target.value)}
407 className="w-full resize-none rounded-field border border-shell-hairline bg-shell-field px-3 py-2 text-[13.5px] outline-none placeholder:text-shell-ink-muted focus:border-shell-ink"
408 />
409 </label>
410 </Modal>
411 </>
412 )
413}
414
415function SpaceCard({
416 space,
417 layout,
418 busy,
419 onOpen,
420 onApply,
421 onWithdraw,
422}: {
423 space: ExploreSpace
424 layout: CardLayout
425 busy: boolean
426 onOpen: () => void
427 onApply: () => void
428 onWithdraw: () => void
429}) {
430 const { t } = useT()
431 const [bannerFailed, setBannerFailed] = useState(false)
432 const request = space.viewer?.request
433 const isMember = space.viewer?.is_member ?? false
434 const now = useNow()
435 // A rejected request only blocks until its cooldown lapses; the server is
436 // authoritative, so the card mirrors the timestamp until it passes, and the
437 // clock ticks so a lapsed cooldown stops reading as "cooling down".
438 const until =
439 request?.status === 'rejected' && request.cooldown_until ? new Date(request.cooldown_until).getTime() : Number.NaN
440 const cooldownUntil = Number.isFinite(until) && until > now ? until : null
441 const banner = !bannerFailed ? space.banner_url : null
442
443 const action = isMember ? (
444 <Button className={`flex-1 ${layout.actionButton}`} onClick={onOpen}>
445 {t('space.discover.enter')}
446 </Button>
447 ) : request?.status === 'pending' ? (
448 <>
449 <span
450 className={`flex-1 rounded-field bg-shell-field px-3 py-1.5 text-center text-shell-ink-muted ${layout.actionText}`}
451 >
452 {t('space.discover.pending')}
453 </span>
454 <Button variant="secondary" className={layout.actionButton} disabled={busy} onClick={onWithdraw}>
455 {t('space.discover.withdraw')}
456 </Button>
457 </>
458 ) : cooldownUntil ? (
459 <span
460 className={`flex-1 rounded-field bg-shell-field px-3 py-1.5 text-center text-shell-ink-muted ${layout.actionText}`}
461 >
462 {t('space.discover.cooldownUntil', { time: fmtDate(new Date(cooldownUntil).toISOString()) })}
463 </span>
464 ) : !space.allow_join ? (
465 <span
466 className={`flex-1 rounded-field bg-shell-field px-3 py-1.5 text-center text-shell-ink-muted ${layout.actionText}`}
467 >
468 {t('space.discover.closed')}
469 </span>
470 ) : (
471 <Button className={`flex-1 ${layout.actionButton}`} onClick={onApply}>
472 {t('space.discover.apply')}
473 </Button>
474 )
475
476 return (
477 <li className="flex flex-col overflow-hidden rounded-xl border border-shell-hairline bg-shell-surface">
478 <div
479 className={`relative w-full ${layout.banner}`}
480 style={banner ? undefined : { background: bannerGradient(space.id) }}
481 >
482 {banner ? (
483 <img
484 src={banner}
485 alt=""
486 loading="lazy"
487 onError={() => setBannerFailed(true)}
488 className="h-full w-full object-cover"
489 />
490 ) : null}
491 <span
492 className={`absolute flex items-center justify-center overflow-hidden rounded-full border-2 border-shell-surface bg-shell-tile font-semibold text-shell-ink ${layout.avatarAnchor} ${layout.avatar}`}
493 >
494 {space.avatar_url ? (
495 <img src={space.avatar_url} alt="" className="h-full w-full object-cover" />
496 ) : (
497 space.name.slice(0, 1).toUpperCase()
498 )}
499 </span>
500 </div>
501 <div className={`flex min-h-0 flex-1 flex-col ${layout.body}`}>
502 <div className="flex items-start justify-between gap-2">
503 <h3 className={`truncate font-semibold ${layout.name}`}>{space.name}</h3>
504 <span className={`flex shrink-0 items-center gap-1 text-shell-ink-muted ${layout.meta}`}>
505 <Users className="h-3.5 w-3.5" aria-hidden />
506 {t('space.discover.members', { count: space.members_count })}
507 </span>
508 </div>
509 <p className={layout.desc}>{space.description || ''}</p>
510 <p className={`mt-2 text-shell-ink-muted ${layout.meta}`}>
511 {t('space.discover.joinLevel', { level: t(JOIN_LEVEL_KEYS[space.allow_join_level]) })}
512 </p>
513 <div className="mt-3 flex items-center gap-2">{action}</div>
514 </div>
515 </li>
516 )
517}
Added view/app/src/domains/space/discovery/index.ts view/app/src/domains/space/discovery/index.ts +16 −0 View view/app/src/domains/space/discovery/index.ts
@@ -0,0 +1,16 @@
1// Public API of features/space-discovery — the public space directory and the
2// applicant side of the join-request flow.
3// Consumers import from '@app/domains/space/discovery', never from deeper files.
4
5export {
6 type AllowJoinLevel,
7 type ExplorePage,
8 type ExploreSpace,
9 type ExploreViewer,
10 type ExploreViewerRequest,
11 exploreSpaces,
12 type JoinRequest,
13 submitJoinRequest,
14 withdrawJoinRequest,
15} from './model/api'
16export { ExploreView, type ExploreViewProps } from './ui/explore-view'
Added view/app/src/domains/space/management/model/api.ts view/app/src/domains/space/management/model/api.ts +119 −0 View view/app/src/domains/space/management/model/api.ts
@@ -0,0 +1,119 @@
1/**
2 * Space-management API: space mutations behind the header menu (create
3 * invite / repo / board / room / dir, update + leave) plus the personal
4 * privacy dialog's preference endpoints.
5 *
6 * `fetchPreferences` / `updatePreferences` intentionally duplicate the
7 * wrappers in features/user-settings: features may not import each other,
8 * and the privacy dialog needs the same two endpoints. Keep both copies
9 * four lines long.
10 */
11
12import { settingsKey, unwrapApi } from '@app/lib'
13import type {
14 CreateBoardCmd,
15 CreateDirCmd,
16 CreateInviteCmd,
17 CreateRepoCmd,
18 CreateRoomCmd,
19 CreateSpaceCmd as CreateSpaceCmdSchema,
20 EnvelopeInviteSummary,
21 EnvelopePageMySpaceDto,
22 EnvelopeSpaceDto,
23 EnvelopeUserPreferencesDto,
24 EnvelopeVecDirNodeDto,
25 UpdatePreferencesCmd,
26 UpdateRoomCmd,
27 UpdateSpaceCmd,
28} from '@app/services/api'
29import Apis from '@app/services/api'
30import { invalidateCache, writeCache } from '@app/services/storage'
31
32export type SpaceDto = NonNullable<EnvelopeSpaceDto['data']>
33export type DirNode = NonNullable<EnvelopeVecDirNodeDto['data']>[number]
34export type MySpace = NonNullable<EnvelopePageMySpaceDto['data']>['items'][number]
35export type UserPreferences = NonNullable<EnvelopeUserPreferencesDto['data']>
36export type CreateSpaceCmd = CreateSpaceCmdSchema
37
38export const createSpace = (cmd: CreateSpaceCmd) => unwrapApi<SpaceDto>(Apis.space.create_space({ data: cmd }))
39
40/** Authoritative full SpaceDto — the /me/spaces payload is a slim subset. */
41export const fetchSpace = (spaceName: string) =>
42 unwrapApi<SpaceDto>(Apis.space.get_space({ pathParams: { space_name: spaceName } }))
43
44export const fetchSpaceDir = (spaceName: string) =>
45 unwrapApi<DirNode[]>(Apis.space.get_space_dir_tree({ pathParams: { space_name: spaceName }, params: {} }))
46
47export const createSpaceDir = (spaceName: string, cmd: CreateDirCmd) =>
48 unwrapApi<DirNode>(Apis.space.create_dir_node({ pathParams: { space_name: spaceName }, data: cmd }))
49
50/**
51 * Move a directory node — folders and the object nodes that stand for a repo or
52 * a board — to another parent (`null` = space root) and position. This is the
53 * only ordering write the tree has, and the sidebar drag-and-drop is the only
54 * caller: `PATCH /spaces/{space}/dir/{dir_id}/position`.
55 */
56export const moveSpaceDir = (spaceName: string, dirId: string, cmd: { parentDirId: string | null; position: number }) =>
57 unwrapApi<DirNode>(
58 Apis.space.move_dir_node({
59 pathParams: { space_name: spaceName, dir_id: dirId },
60 data: { parent_dir_id: cmd.parentDirId, position: cmd.position },
61 }),
62 )
63
64/**
65 * Reorder a channel and/or file it into a folder. `parent_id` is a three-state
66 * field on the backend (`null` = move to the space root); it is added through an
67 * intersection until `bun run gen:api` sees it in a refreshed schema. The
68 * response is the room's own row, so the caller can verify the server actually
69 * applied the parent — an apihost built without it silently ignores the key.
70 */
71export const moveRoom = (roomId: string, cmd: { position: number; parentId: string | null }) =>
72 unwrapApi<{ parent_id?: string | null }>(
73 Apis.chat.update_room({
74 pathParams: { room_id: roomId },
75 data: {
76 position: cmd.position,
77 parent_id: cmd.parentId,
78 } as UpdateRoomCmd & { parent_id?: string | null },
79 }),
80 )
81
82export const createInvite = (spaceName: string, cmd: CreateInviteCmd) =>
83 unwrapApi<NonNullable<EnvelopeInviteSummary['data']>>(
84 Apis.space.create_invite({ pathParams: { space_name: spaceName }, data: cmd }),
85 )
86
87/**
88 * Accept an invitation by the code its email carried. Mirrors the notification
89 * inbox's wrapper: features may not import each other, so the two copies stay
90 * deliberately small.
91 */
92export const acceptSpaceInvite = (token: string) =>
93 unwrapApi<unknown>(Apis.space.space_invite_redeem({ data: { token } }))
94
95export const createRepo = (spaceName: string, cmd: CreateRepoCmd) =>
96 unwrapApi<unknown>(Apis.repos.create_repo({ pathParams: { space_name: spaceName }, data: cmd }))
97
98export const createBoard = (spaceName: string, cmd: CreateBoardCmd) =>
99 unwrapApi<unknown>(Apis.issues.create_board({ pathParams: { space_name: spaceName }, data: cmd }))
100
101export const createRoom = (spaceName: string, cmd: CreateRoomCmd) =>
102 unwrapApi<unknown>(Apis.chat.create_room({ pathParams: { space_name: spaceName }, data: cmd }))
103
104export const updateSpace = (spaceName: string, cmd: UpdateSpaceCmd) =>
105 unwrapApi<SpaceDto>(Apis.space.update_space({ pathParams: { space_name: spaceName }, data: cmd }))
106
107export const leaveSpace = (spaceName: string) =>
108 unwrapApi<boolean>(Apis.space.space_leave({ pathParams: { space_name: spaceName } }))
109
110export const fetchPreferences = () => unwrapApi<UserPreferences>(Apis.account.get_preferences())
111
112export const updatePreferences = (cmd: UpdatePreferencesCmd) =>
113 unwrapApi<UserPreferences>(Apis.account.update_preferences({ data: cmd })).then((prefs) => {
114 // Mirror of the user-settings write-through (features may not import each
115 // other): keep the shared preferences cache truthful after the dialog saves.
116 writeCache(settingsKey('preferences'), prefs)
117 invalidateCache(settingsKey('diff-style'))
118 return prefs
119 })
Added view/app/src/domains/space/management/model/space-admin.ts view/app/src/domains/space/management/model/space-admin.ts +406 −0 View view/app/src/domains/space/management/model/space-admin.ts
@@ -0,0 +1,406 @@
1/**
2 * Space-administration API: every space endpoint behind the community settings
3 * overlay (members, roles, invites, join rules, bans, channel overwrites,
4 * images, directory maintenance). The header menu's create flows live in
5 * `./api`; this module covers the read/manage surface they never reached.
6 *
7 * The generated client collapses all paged space lists into one untyped `Page`
8 * schema, so each row type is asserted here from its single-resource envelope
9 * (`EnvelopeMemberDto` etc.) instead of being read off the list call.
10 */
11
12import { type ApiEnvelope, ApiFailure, unwrapApi } from '@app/lib'
13import type {
14 AddMemberCmd,
15 AssignGroupCmd,
16 CreateGroupCmd,
17 CreateInviteCmd,
18 CreateJoinRuleCmd,
19 EnvelopeGroupDto,
20 EnvelopeInviteSummary,
21 EnvelopeJoinRequestDto,
22 EnvelopeJoinRuleDto,
23 EnvelopeMemberDto,
24 EnvelopeMemberSummary,
25 EnvelopeSpaceImageDto,
26 EnvelopeUserPublic,
27 EnvelopeVecCleanupObjectDto,
28 EnvelopeVecGroupOverwriteDto,
29 EnvelopeVecUserOverwriteDto,
30 MemberStatus,
31 SpaceImageKind,
32 SpaceJoinRequestStatus,
33 UpdateBanCmd,
34 UpdateGroupCmd,
35 UpdateJoinRuleCmd,
36 UpdateMemberCmd,
37} from '@app/services/api'
38import Apis, { trackFetch } from '@app/services/api'
39
40export type SpaceMember = NonNullable<EnvelopeMemberDto['data']>
41export type SpaceGroup = NonNullable<EnvelopeGroupDto['data']>
42export type SpaceInvite = NonNullable<EnvelopeInviteSummary['data']>
43export type SpaceBan = NonNullable<EnvelopeMemberSummary['data']>
44export type SpaceJoinRule = NonNullable<EnvelopeJoinRuleDto['data']>
45export type SpaceImage = NonNullable<EnvelopeSpaceImageDto['data']>
46export type DirGroupOverwrite = NonNullable<EnvelopeVecGroupOverwriteDto['data']>[number]
47export type DirUserOverwrite = NonNullable<EnvelopeVecUserOverwriteDto['data']>[number]
48export type DirOverwrite = DirGroupOverwrite | DirUserOverwrite
49export type PublicUser = NonNullable<EnvelopeUserPublic['data']>
50
51/* ---------------------------------------------------------------- cleanup */
52
53/** 对象清理清单里的一行:目录节点或频道(见 webapi::cleanup)。 */
54export type CleanupObject = NonNullable<EnvelopeVecCleanupObjectDto['data']>[number]
55export type { MemberStatus, SpaceImageKind }
56
57const pageRows = async <T>(method: unknown): Promise<T[]> => {
58 const page = await unwrapApi<{ items?: T[] } | null>(method as PromiseLike<ApiEnvelope<{ items?: T[] } | null>>)
59 return page?.items ?? []
60}
61
62const rows = async <T>(method: unknown): Promise<T[]> => {
63 const list = await unwrapApi<T[] | null>(method as PromiseLike<ApiEnvelope<T[] | null>>)
64 return list ?? []
65}
66
67/** Paged list that keeps `total`/`offset`: the join-request queue pages. */
68const pageOf = async <T>(method: unknown): Promise<{ items: T[]; total: number; limit: number; offset: number }> => {
69 const page = await unwrapApi<{ items?: T[]; total?: number; limit?: number; offset?: number } | null>(
70 method as PromiseLike<ApiEnvelope<{ items?: T[]; total?: number; limit?: number; offset?: number } | null>>,
71 )
72 return {
73 items: page?.items ?? [],
74 total: page?.total ?? 0,
75 limit: page?.limit ?? 0,
76 offset: page?.offset ?? 0,
77 }
78}
79
80/** Page size for every settings list; the overlay has no pagination controls. */
81export const ADMIN_PAGE_LIMIT = 200
82
83/* ---------------------------------------------------------------- identity */
84
85/** Resolve a username to the ids the member/ban commands need. */
86export const findUserByUsername = (username: string) =>
87 unwrapApi<PublicUser>(Apis.users.get_user_by_username({ pathParams: { username: username.replace(/^@/, '') } }))
88
89/* ----------------------------------------------------------------- members */
90
91export const listSpaceMembers = (spaceName: string) =>
92 pageRows<SpaceMember>(
93 Apis.space.list_space_members({
94 pathParams: { space_name: spaceName },
95 params: { limit: ADMIN_PAGE_LIMIT },
96 }),
97 )
98
99export const getSpaceMember = (spaceName: string, userId: number) =>
100 unwrapApi<SpaceMember>(Apis.space.get_member({ pathParams: { space_name: spaceName, user_id: userId } }))
101
102export const addSpaceMember = (spaceName: string, cmd: AddMemberCmd) =>
103 unwrapApi<SpaceMember>(Apis.space.add_member({ pathParams: { space_name: spaceName }, data: cmd }))
104
105export const updateSpaceMember = (spaceName: string, userId: number, cmd: UpdateMemberCmd) =>
106 unwrapApi<SpaceMember>(
107 Apis.space.update_member({ pathParams: { space_name: spaceName, user_id: userId }, data: cmd }),
108 )
109
110/** The viewer's own row: nickname alias and notification prefs need no rights. */
111export const updateMySpaceMembership = (spaceName: string, cmd: UpdateMemberCmd) =>
112 unwrapApi<SpaceMember>(Apis.space.update_self_member({ pathParams: { space_name: spaceName }, data: cmd }))
113
114export const removeSpaceMember = (spaceName: string, userId: number) =>
115 unwrapApi<null>(Apis.space.remove_member({ pathParams: { space_name: spaceName, user_id: userId } }))
116
117/* ------------------------------------------------------------ roles/groups */
118
119export const listSpaceGroups = (spaceName: string) =>
120 pageRows<SpaceGroup>(
121 Apis.space.list_groups({
122 pathParams: { space_name: spaceName },
123 params: { limit: ADMIN_PAGE_LIMIT, sort: 'position' },
124 }),
125 )
126
127export const createSpaceGroup = (spaceName: string, cmd: CreateGroupCmd) =>
128 unwrapApi<SpaceGroup>(Apis.space.create_group({ pathParams: { space_name: spaceName }, data: cmd }))
129
130export const updateSpaceGroup = (spaceName: string, groupId: string, cmd: UpdateGroupCmd) =>
131 unwrapApi<SpaceGroup>(
132 Apis.space.update_group({ pathParams: { space_name: spaceName, group_id: groupId }, data: cmd }),
133 )
134
135export const deleteSpaceGroup = (spaceName: string, groupId: string) =>
136 unwrapApi<null>(Apis.space.delete_group({ pathParams: { space_name: spaceName, group_id: groupId } }))
137
138/** Group membership is a set write, not a field on the member row. */
139export const assignSpaceGroup = (spaceName: string, userId: number, groupId: string) =>
140 unwrapApi<null>(
141 Apis.space.assign_group({
142 pathParams: { space_name: spaceName, user_id: userId },
143 data: { group_id: groupId } satisfies AssignGroupCmd,
144 }),
145 )
146
147export const unassignSpaceGroup = (spaceName: string, userId: number, groupId: string) =>
148 unwrapApi<null>(
149 Apis.space.unassign_group({
150 pathParams: { space_name: spaceName, user_id: userId, group_id: groupId },
151 }),
152 )
153
154export const joinSpaceGroup = (spaceName: string, groupId: string) =>
155 unwrapApi<null>(Apis.space.join_group_self({ pathParams: { space_name: spaceName, group_id: groupId } }))
156
157export const leaveSpaceGroup = (spaceName: string, groupId: string) =>
158 unwrapApi<null>(Apis.space.leave_group_self({ pathParams: { space_name: spaceName, group_id: groupId } }))
159
160/* ----------------------------------------------------------------- invites */
161
162export const listSpaceInvites = (spaceName: string) =>
163 pageRows<SpaceInvite>(
164 Apis.space.list_invites({
165 pathParams: { space_name: spaceName },
166 params: { limit: ADMIN_PAGE_LIMIT },
167 }),
168 )
169
170export const revokeSpaceInvite = (spaceName: string, inviteId: string) =>
171 unwrapApi<SpaceInvite | null>(
172 Apis.space.revoke_invite({ pathParams: { space_name: spaceName, invite_id: inviteId } }),
173 )
174
175export const createSpaceInvite = (spaceName: string, cmd: CreateInviteCmd) =>
176 unwrapApi<SpaceInvite>(Apis.space.create_invite({ pathParams: { space_name: spaceName }, data: cmd }))
177
178/** Redeem an invite code received out of band; works for a non-member too. */
179
180/* ------------------------------------------------------------ join rules */
181
182export const listSpaceJoinRules = (spaceName: string) =>
183 rows<SpaceJoinRule>(Apis.space.list_join_rules({ pathParams: { space_name: spaceName } }))
184
185export const createSpaceJoinRule = (spaceName: string, cmd: CreateJoinRuleCmd) =>
186 unwrapApi<SpaceJoinRule>(Apis.space.create_join_rule({ pathParams: { space_name: spaceName }, data: cmd }))
187
188export const updateSpaceJoinRule = (spaceName: string, ruleId: string, cmd: UpdateJoinRuleCmd) =>
189 unwrapApi<SpaceJoinRule>(
190 Apis.space.update_join_rule({ pathParams: { space_name: spaceName, rule_id: ruleId }, data: cmd }),
191 )
192
193export const deleteSpaceJoinRule = (spaceName: string, ruleId: string) =>
194 unwrapApi<null>(Apis.space.delete_join_rule({ pathParams: { space_name: spaceName, rule_id: ruleId } }))
195
196/* ---------------------------------------------------------- join requests */
197
198export type SpaceJoinRequest = NonNullable<EnvelopeJoinRequestDto['data']>
199
200export const listSpaceJoinRequests = (
201 spaceName: string,
202 query: { status?: SpaceJoinRequestStatus; limit?: number; offset?: number } = {},
203) =>
204 pageOf<SpaceJoinRequest>(
205 Apis.space.list_join_requests({
206 pathParams: { space_name: spaceName },
207 params: {
208 ...(query.status ? { status: query.status } : {}),
209 ...(query.limit === undefined ? {} : { limit: query.limit }),
210 ...(query.offset === undefined ? {} : { offset: query.offset }),
211 },
212 }),
213 )
214
215export const approveSpaceJoinRequest = (spaceName: string, requestId: string) =>
216 unwrapApi<SpaceJoinRequest>(
217 Apis.space.approve_join_request({
218 pathParams: { space_name: spaceName, request_id: requestId },
219 }),
220 )
221
222export const rejectSpaceJoinRequest = (spaceName: string, requestId: string) =>
223 unwrapApi<SpaceJoinRequest>(
224 Apis.space.reject_join_request({
225 pathParams: { space_name: spaceName, request_id: requestId },
226 }),
227 )
228
229/* ------------------------------------------------------------------ bans */
230
231export const listSpaceBans = (spaceName: string) =>
232 pageRows<SpaceBan>(
233 Apis.space.list_bans({
234 pathParams: { space_name: spaceName },
235 params: { limit: ADMIN_PAGE_LIMIT },
236 }),
237 )
238
239export const banSpaceUser = async (
240 spaceName: string,
241 cmd: { user_id: number; reason?: string | null; expires_at?: string | null },
242): Promise<SpaceBan> => {
243 const member = await findSpaceMember(spaceName, cmd.user_id)
244 return unwrapApi<SpaceBan>(
245 Apis.space.space_member_ban({
246 pathParams: { space_name: spaceName, member_id: member.id },
247 data: { reason: cmd.reason ?? null },
248 }),
249 )
250}
251
252export const findSpaceMember = async (spaceName: string, userId: number) => {
253 const page = await unwrapApi<{ items?: { id: string; user_id: number }[] }>(
254 Apis.space.space_member_list({ pathParams: { space_name: spaceName }, params: { limit: 100 } }),
255 )
256 const hit = page?.items?.find((row) => row.user_id === userId)
257 if (!hit) throw new ApiFailure('成员不存在', 404)
258 return hit
259}
260
261export const updateSpaceBan = (spaceName: string, userId: number, cmd: UpdateBanCmd) =>
262 unwrapApi<SpaceBan>(Apis.space.update_ban({ pathParams: { space_name: spaceName, user_id: userId }, data: cmd }))
263
264export const unbanSpaceUser = (spaceName: string, userId: number) =>
265 unwrapApi<null>(Apis.space.space_ban_delete({ pathParams: { space_name: spaceName, user_id: userId } }))
266
267/* ----------------------------------------------- channel permission edits */
268
269export const listDirGroupOverwrites = (spaceName: string, dirId: string) =>
270 rows<DirGroupOverwrite>(
271 Apis.space.list_dir_group_overwrites({ pathParams: { space_name: spaceName, dir_id: dirId } }),
272 )
273
274export const listDirUserOverwrites = (spaceName: string, dirId: string) =>
275 rows<DirUserOverwrite>(Apis.space.list_dir_user_overwrites({ pathParams: { space_name: spaceName, dir_id: dirId } }))
276
277export const setDirGroupOverwrite = (
278 spaceName: string,
279 dirId: string,
280 groupId: string,
281 cmd: { allow_mask: number; deny_mask: number },
282) =>
283 unwrapApi<DirGroupOverwrite>(
284 Apis.space.set_dir_group_overwrite({
285 pathParams: { space_name: spaceName, dir_id: dirId, group_id: groupId },
286 data: cmd,
287 }),
288 )
289
290export const removeDirGroupOverwrite = (spaceName: string, dirId: string, groupId: string) =>
291 unwrapApi<null>(
292 Apis.space.remove_dir_group_overwrite({
293 pathParams: { space_name: spaceName, dir_id: dirId, group_id: groupId },
294 }),
295 )
296
297export const setDirUserOverwrite = (
298 spaceName: string,
299 dirId: string,
300 userId: number,
301 cmd: { allow_mask: number; deny_mask: number },
302) =>
303 unwrapApi<DirUserOverwrite>(
304 Apis.space.set_dir_user_overwrite({
305 pathParams: { space_name: spaceName, dir_id: dirId, user_id: userId },
306 data: cmd,
307 }),
308 )
309
310export const removeDirUserOverwrite = (spaceName: string, dirId: string, userId: number) =>
311 unwrapApi<null>(
312 Apis.space.remove_dir_user_overwrite({
313 pathParams: { space_name: spaceName, dir_id: dirId, user_id: userId },
314 }),
315 )
316
317export const renameSpaceDir = (spaceName: string, dirId: string, name: string) =>
318 unwrapApi<unknown>(
319 Apis.space.update_dir_node({
320 pathParams: { space_name: spaceName, dir_id: dirId },
321 data: { name },
322 }),
323 )
324
325/** Owner-only maintenance: clears dangling parents and cycles in the tree. */
326export const repairSpaceDirs = (spaceName: string) =>
327 unwrapApi<{ fixed: number; scanned: number }>(
328 Apis.space.repair_dir_parents({ pathParams: { space_name: spaceName } }),
329 )
330
331/* ----------------------------------------------------------------- images */
332
333export const getSpaceImage = (spaceName: string, kind: SpaceImageKind) =>
334 unwrapApi<SpaceImage | null>(Apis.space.get_space_image({ pathParams: { space_name: spaceName, kind } }))
335
336/** Binary upload, same contract as `PUT /me/avatar`: the raw file bytes are the
337 * request body, so the server never decodes base64, and the intrinsic size rides
338 * along as query params because it cannot live in a byte stream. */
339export async function uploadSpaceImage(
340 spaceName: string,
341 kind: SpaceImageKind,
342 file: File,
343 size?: { width: number; height: number } | null,
344): Promise<SpaceImage> {
345 const query = new URLSearchParams()
346 if (size?.width) query.set('width', String(size.width))
347 if (size?.height) query.set('height', String(size.height))
348 const params = query.toString()
349 const res = await trackFetch(
350 `/api/v1/spaces/${encodeURIComponent(spaceName)}/images/${kind}${params ? `?${params}` : ''}`,
351 {
352 method: 'PUT',
353 body: file,
354 headers: { 'content-type': file.type || 'application/octet-stream' },
355 },
356 )
357 const env = (await res.json()) as ApiEnvelope<SpaceImage>
358 if (env.code !== 200) {
359 throw new ApiFailure(env.error || env.message || 'space image upload failed', env.code, env.error_code)
360 }
361 return env.data as SpaceImage
362}
363
364export const deleteSpaceImage = (spaceName: string, kind: SpaceImageKind) =>
365 unwrapApi<null>(Apis.space.delete_space_image({ pathParams: { space_name: spaceName, kind } }))
366
367/* ----------------------------------------------------------------- removal */
368
369/** Owner only, and soft: the space stops being visible but keeps its rows. */
370export const deleteSpace = (spaceName: string) =>
371 unwrapApi<null>(Apis.space.delete_space({ pathParams: { space_name: spaceName } }))
372
373/* ----------------------------------------------------------------- cleanup */
374
375export const listCleanupObjects = (spaceName: string) =>
376 rows<CleanupObject>(Apis.cleanup.list_cleanup_objects({ pathParams: { space_name: spaceName } }))
377
378export const setCleanupVisibility = (spaceName: string, kind: string, objectId: string, isPrivate: boolean) =>
379 unwrapApi<null>(
380 Apis.cleanup.set_cleanup_visibility({
381 pathParams: { space_name: spaceName, kind, object_id: objectId },
382 data: { is_private: isPrivate },
383 }),
384 )
385
386export const setCleanupArchived = (spaceName: string, kind: string, objectId: string, archived: boolean) =>
387 unwrapApi<null>(
388 Apis.cleanup.set_cleanup_archive({
389 pathParams: { space_name: spaceName, kind, object_id: objectId },
390 data: { archived },
391 }),
392 )
393
394export const deleteCleanupObject = (spaceName: string, kind: string, objectId: string) =>
395 unwrapApi<null>(
396 Apis.cleanup.delete_cleanup_object({
397 pathParams: { space_name: spaceName, kind, object_id: objectId },
398 }),
399 )
400
401export const clearCleanupOverwrites = (spaceName: string, kind: string, objectId: string) =>
402 unwrapApi<null>(
403 Apis.cleanup.clear_cleanup_overwrites({
404 pathParams: { space_name: spaceName, kind, object_id: objectId },
405 }),
406 )
Added view/app/src/domains/space/management/ui/space-settings/bans-section.tsx view/app/src/domains/space/management/ui/space-settings/bans-section.tsx +205 −0 View view/app/src/domains/space/management/ui/space-settings/bans-section.tsx
@@ -0,0 +1,205 @@
1import { Alert } from '@app/components'
2import {
3 EmptyNote,
4 Field,
5 fmtDate,
6 InitialAvatar,
7 inputCls,
8 SectionTitle,
9 SettingRow,
10 Toggle,
11 useSave,
12} from '@app/domains/shared'
13import { t as tr, useT } from '@app/i18n'
14import { spaceAdminKey, useAsync } from '@app/lib'
15import { Button } from '@app/ui'
16import { useState } from 'react'
17import {
18 banSpaceUser,
19 findUserByUsername,
20 listSpaceBans,
21 type SpaceBan,
22 unbanSpaceUser,
23 updateSpaceBan,
24} from '../../model/space-admin'
25
26import type { SpaceSectionProps } from './props'
27
28const BAN_FLAG_KEYS = {
29 allow_speak: 'space.bans.allowSpeak',
30 allow_push_repo: 'space.bans.allowPushRepo',
31 allow_comment_any: 'space.bans.allowCommentAny',
32} as const
33
34const BAN_FLAGS = (Object.keys(BAN_FLAG_KEYS) as Array<keyof typeof BAN_FLAG_KEYS>).map((key) => ({
35 key,
36 get label() {
37 return tr(BAN_FLAG_KEYS[key])
38 },
39}))
40
41type BanFlag = (typeof BAN_FLAGS)[number]['key']
42
43/** Ban list: who is blocked, until when, and which exceptions they keep. */
44export function BansSection({ spaceName }: SpaceSectionProps) {
45 const { t } = useT()
46 const [tick, setTick] = useState(0)
47 const { busy, notice, error, run } = useSave()
48 const bans = useAsync(() => listSpaceBans(spaceName), [spaceName, tick], {
49 cacheKey: spaceAdminKey(spaceName, 'bans'),
50 })
51 const [handle, setHandle] = useState('')
52 const [reason, setReason] = useState('')
53 const [expires, setExpires] = useState('')
54 const refresh = () => setTick((t) => t + 1)
55
56 return (
57 <section>
58 <SectionTitle title={t('space.settings.sections.bans')} sub={t('space.bans.sub')} />
59 <div className="settings-card mb-6 p-4">
60 <div className="grid gap-x-4 sm:grid-cols-2">
61 <Field label={t('space.invites.username')}>
62 <input className={inputCls} placeholder="ada" value={handle} onChange={(e) => setHandle(e.target.value)} />
63 </Field>
64 <Field label={t('space.bans.expires')} hint={t('space.bans.expiresHint')}>
65 <input
66 type="datetime-local"
67 className={inputCls}
68 value={expires}
69 onChange={(e) => setExpires(e.target.value)}
70 />
71 </Field>
72 </div>
73 <Field label={t('space.bans.reason')} hint={t('space.bans.reasonHint')}>
74 <input className={inputCls} maxLength={200} value={reason} onChange={(e) => setReason(e.target.value)} />
75 </Field>
76 <div className="flex flex-wrap items-center gap-3">
77 <Button
78 className="h-9"
79 disabled={busy || !handle.trim()}
80 onClick={() =>
81 void run(async () => {
82 const found = await findUserByUsername(handle.trim())
83 await banSpaceUser(spaceName, {
84 user_id: found.user_id,
85 reason: reason.trim() || null,
86 expires_at: expires ? new Date(expires).toISOString() : null,
87 })
88 setHandle('')
89 setReason('')
90 setExpires('')
91 refresh()
92 }, t('space.bans.banned'))
93 }
94 >
95 {t('space.bans.banUser')}
96 </Button>
97 {error ? <Alert tone="error">{error}</Alert> : null}
98 {notice && !error ? <Alert tone="success">{notice}</Alert> : null}
99 </div>
100 </div>
101
102 {bans.loading ? (
103 <EmptyNote>{t('space.bans.loading')}</EmptyNote>
104 ) : (bans.data ?? []).length === 0 ? (
105 <EmptyNote>{t('space.bans.empty')}</EmptyNote>
106 ) : (
107 <div className="space-y-2">
108 {bans.data!.map((ban) => (
109 <BanRow key={`${ban.user_id}`} spaceName={spaceName} ban={ban} busy={busy} onSaved={refresh} />
110 ))}
111 </div>
112 )}
113 </section>
114 )
115}
116
117function BanRow({
118 spaceName,
119 ban,
120 busy,
121 onSaved,
122}: {
123 spaceName: string
124 ban: SpaceBan
125 busy: boolean
126 onSaved: () => void
127}) {
128 const [flags, setFlags] = useState<Record<BanFlag, boolean>>(() => ({
129 allow_speak: ban.ban_allow_speak,
130 allow_push_repo: ban.ban_allow_push_repo,
131 allow_comment_any: ban.ban_allow_comment_any,
132 }))
133 const [reason, setReason] = useState(ban.ban_reason ?? '')
134 const { t } = useT()
135 const { run, busy: rowBusy, notice, error } = useSave()
136 const userId = ban.user_id
137
138 return (
139 <div className="settings-card">
140 <SettingRow
141 lead={<InitialAvatar name={ban.alias_name || ban.displayname || ban.username} url={ban.alias_avatar_url} />}
142 title={ban.alias_name || ban.displayname || ban.username}
143 meta={
144 <>
145 {ban.ban_reason ? `${ban.ban_reason} · ` : ''}
146 {ban.ban_expires_at
147 ? t('space.bans.unbanAt', { date: fmtDate(ban.ban_expires_at) })
148 : t('space.bans.permanent')}{' '}
149 · {t('space.bans.by', { by: ban.username, date: fmtDate(ban.banned_at ?? ban.join_method) })}
150 </>
151 }
152 actions={
153 userId ? (
154 <Button
155 variant="secondary"
156 className="h-8 text-[12.5px]"
157 disabled={busy || rowBusy}
158 onClick={() =>
159 void run(async () => {
160 await unbanSpaceUser(spaceName, userId)
161 onSaved()
162 }, t('space.bans.unbanned'))
163 }
164 >
165 {t('space.bans.unban')}
166 </Button>
167 ) : null
168 }
169 />
170 <div className="settings-subpanel">
171 <Field label={t('space.bans.reason')}>
172 <input className={inputCls} maxLength={200} value={reason} onChange={(e) => setReason(e.target.value)} />
173 </Field>
174 <div className="grid gap-x-6 sm:grid-cols-2">
175 {BAN_FLAGS.map((flag) => (
176 <Toggle
177 key={flag.key}
178 checked={flags[flag.key]}
179 onChange={(v) => setFlags({ ...flags, [flag.key]: v })}
180 label={flag.label}
181 />
182 ))}
183 </div>
184 <div className="flex flex-wrap items-center gap-3">
185 <Button
186 variant="secondary"
187 className="h-9 text-[12.5px]"
188 disabled={busy || rowBusy || !userId}
189 onClick={() =>
190 void run(async () => {
191 if (!userId) throw new Error(t('space.bans.missingUser'))
192 await updateSpaceBan(spaceName, userId, { reason: reason.trim() || null, ...flags })
193 onSaved()
194 }, t('space.bans.saved'))
195 }
196 >
197 {t('space.bans.saveExceptions')}
198 </Button>
199 {notice ? <Alert tone="success">{notice}</Alert> : null}
200 {error ? <Alert tone="error">{error}</Alert> : null}
201 </div>
202 </div>
203 </div>
204 )
205}
Added view/app/src/domains/space/management/ui/space-settings/channel-permissions-section.tsx view/app/src/domains/space/management/ui/space-settings/channel-permissions-section.tsx +394 −0 View view/app/src/domains/space/management/ui/space-settings/channel-permissions-section.tsx
@@ -0,0 +1,394 @@
1import { Alert } from '@app/components'
2import { Chip, EmptyNote, Field, fmtDate, inputCls, SectionTitle, SettingRow, useSave } from '@app/domains/shared'
3import { t as tr, useT } from '@app/i18n'
4import { spaceAdminKey, spaceAdminMembersKey, spaceDirKey, useAsync } from '@app/lib'
5import { Button } from '@app/ui'
6import { useState } from 'react'
7import { fetchSpaceDir } from '../../model/api'
8import {
9 findUserByUsername,
10 listDirGroupOverwrites,
11 listDirUserOverwrites,
12 listSpaceGroups,
13 listSpaceMembers,
14 removeDirGroupOverwrite,
15 removeDirUserOverwrite,
16 renameSpaceDir,
17 repairSpaceDirs,
18 setDirGroupOverwrite,
19 setDirUserOverwrite,
20} from '../../model/space-admin'
21import { allDirNodes, PERM, PERM_DIR_VISIBLE, userLabel } from './meta'
22import type { SpaceSectionProps } from './props'
23
24/** Tri-state of one subject on one node: hidden, inherited, or forced visible. */
25type Stance = 'deny' | 'inherit' | 'allow'
26
27const stanceOf = (allow: number, deny: number): Stance =>
28 deny & PERM_DIR_VISIBLE ? 'deny' : allow & PERM_DIR_VISIBLE ? 'allow' : 'inherit'
29
30const STANCE_KEYS = {
31 deny: 'space.channelPermissions.stance.deny',
32 inherit: 'space.channelPermissions.stance.inherit',
33 allow: 'space.channelPermissions.stance.allow',
34} as const
35
36const STANCES: { value: Stance; label: string }[] = (Object.keys(STANCE_KEYS) as Stance[]).map((value) => ({
37 value,
38 get label() {
39 return tr(STANCE_KEYS[value])
40 },
41}))
42
43/**
44 * Per-node visibility overrides. The server stores a single mask bit today
45 * (`PERM_DIR_VISIBLE`), so the editor is a hide / inherit / show switch.
46 */
47export function ChannelPermissionsSection({ spaceName, perms, isOwner }: SpaceSectionProps) {
48 const { t, tc } = useT()
49 const [tick, setTick] = useState(0)
50 const [dirId, setDirId] = useState('')
51 const [revision, setRevision] = useState(0)
52 const { busy, notice, error, run } = useSave()
53 const canRename = perms.has(PERM.channels)
54 const canOverwrite = perms.has(PERM.overwrites)
55 const tree = useAsync(() => fetchSpaceDir(spaceName), [spaceName, tick], {
56 // Same key as the shell's sidebar tree: one payload, two consumers.
57 cacheKey: spaceDirKey(spaceName),
58 })
59 const nodes = allDirNodes(tree.data ?? [])
60 const active = dirId || nodes[0]?.node.id || ''
61 const activeNode = nodes.find((entry) => entry.node.id === active)?.node
62 const groups = useAsync(() => listSpaceGroups(spaceName), [spaceName], {
63 cacheKey: spaceAdminKey(spaceName, 'groups'),
64 })
65 // The member list needs manage_members; without it the editor falls back to
66 // naming a user, which `findUserByUsername` resolves on demand. The empty
67 // fallback must not be stored under the shared admin key.
68 const members = useAsync(
69 () =>
70 perms.has(PERM.members)
71 ? listSpaceMembers(spaceName)
72 : Promise.resolve([] as Awaited<ReturnType<typeof listSpaceMembers>>),
73 [spaceName, perms.has(PERM.members)],
74 { cacheKey: perms.has(PERM.members) ? spaceAdminMembersKey(spaceName) : null },
75 )
76 const overwrite = useAsync(
77 async () => {
78 if (!active) return { groups: [], users: [] }
79 const [g, u] = await Promise.all([
80 listDirGroupOverwrites(spaceName, active).catch(() => []),
81 listDirUserOverwrites(spaceName, active).catch(() => []),
82 ])
83 return { groups: g, users: u }
84 },
85 [spaceName, active, revision],
86 { cacheKey: active ? spaceAdminKey(spaceName, `overwrites:${active}`) : null },
87 )
88 const refresh = () => setTick((t) => t + 1)
89
90 const setStance = (kind: 'group' | 'user', id: string | number, stance: Stance) =>
91 void run(async () => {
92 if (!active) return
93 if (stance === 'inherit') {
94 if (kind === 'group') await removeDirGroupOverwrite(spaceName, active, String(id))
95 else await removeDirUserOverwrite(spaceName, active, Number(id))
96 } else {
97 const mask = {
98 allow_mask: stance === 'allow' ? PERM_DIR_VISIBLE : 0,
99 deny_mask: stance === 'deny' ? PERM_DIR_VISIBLE : 0,
100 }
101 if (kind === 'group') await setDirGroupOverwrite(spaceName, active, String(id), mask)
102 else await setDirUserOverwrite(spaceName, active, Number(id), mask)
103 }
104 setRevision((r) => r + 1)
105 }, t('space.channelPermissions.saved'))
106
107 return (
108 <section>
109 <SectionTitle title={t('space.channelPermissions.title')} sub={t('space.channelPermissions.sub2')} />
110 <div className="mb-5 flex flex-wrap items-center gap-2">
111 <select
112 className={`${inputCls} h-9 w-auto max-w-[340px]`}
113 value={active}
114 onChange={(e) => setDirId(e.target.value)}
115 aria-label={t('space.channelPermissions.selectNode')}
116 >
117 {nodes.length === 0 ? <option value="">{t('space.channelPermissions.emptyTree')}</option> : null}
118 {nodes.map(({ node, depth }) => (
119 <option key={node.id} value={node.id}>
120 {`${' '.repeat(depth)}${node.kind === 'Dir' ? '▸ ' : '· '}${node.name}`}
121 </option>
122 ))}
123 </select>
124 {isOwner ? (
125 <Button
126 variant="secondary"
127 className="h-9 text-[12.5px]"
128 disabled={busy}
129 onClick={() =>
130 void run(
131 async () => {
132 const result = await repairSpaceDirs(spaceName)
133 refresh()
134 return result
135 },
136 (result) => t('space.channelPermissions.repaired', result),
137 )
138 }
139 >
140 {t('space.channelPermissions.repair')}
141 </Button>
142 ) : null}
143 </div>
144 {error ? (
145 <div className="mb-3">
146 <Alert tone="error">{error}</Alert>
147 </div>
148 ) : null}
149 {!activeNode ? (
150 <EmptyNote>{t('space.channelPermissions.emptyNote')}</EmptyNote>
151 ) : (
152 <>
153 <div className="settings-card mb-5">
154 <SettingRow
155 title={activeNode.name}
156 meta={
157 <>
158 {activeNode.kind === 'Dir'
159 ? t('space.channelPermissions.folder')
160 : t('space.channelPermissions.entry')}{' '}
161 · {t('space.channelPermissions.position', { position: activeNode.position })} ·{' '}
162 {t('space.roles.createdAt', { date: fmtDate(activeNode.created_at) })}
163 {activeNode.is_archived ? ` · ${t('space.channelPermissions.archived')}` : ''}
164 </>
165 }
166 actions={<Chip>{activeNode.topic ? activeNode.topic : t('space.channelPermissions.noTopic')}</Chip>}
167 />
168 {canRename ? (
169 <RenameField
170 key={activeNode.id}
171 spaceName={spaceName}
172 nodeId={activeNode.id}
173 current={activeNode.name}
174 busy={busy}
175 run={run}
176 onRenamed={refresh}
177 />
178 ) : null}
179 </div>
180
181 <h3 className="mb-2 text-[14px] font-bold">{t('space.channelPermissions.byRole')}</h3>
182 {(groups.data ?? []).length === 0 ? (
183 <EmptyNote>{t('space.roles.emptyShort')}</EmptyNote>
184 ) : (
185 <div className="mb-8 space-y-2">
186 {(groups.data ?? []).map((group) => {
187 const row = overwrite.data?.groups.find((entry) => entry.group_id === group.id)
188 return (
189 <StanceRow
190 key={group.id}
191 title={group.unicode_emoji ? `${group.unicode_emoji} ${group.name}` : group.name}
192 meta={tc('space.roles.permissionCount', group.permissions.length)}
193 stance={stanceOf(row?.allow_mask ?? 0, row?.deny_mask ?? 0)}
194 busy={busy || overwrite.loading || !canOverwrite}
195 onChange={(value) => setStance('group', group.id, value)}
196 />
197 )
198 })}
199 </div>
200 )}
201
202 <h3 className="mb-2 text-[14px] font-bold">{t('space.channelPermissions.byMember')}</h3>
203 <div className="space-y-2">
204 {(members.data ?? []).map((member) => {
205 const userId = member.user?.user_id
206 if (!userId) return null
207 const row = overwrite.data?.users.find((entry) => entry.user_id === userId)
208 return (
209 <StanceRow
210 key={userId}
211 title={userLabel(member.user)}
212 meta={member.alias_name ?? t('space.channelPermissions.defaultAlias')}
213 stance={stanceOf(row?.allow_mask ?? 0, row?.deny_mask ?? 0)}
214 busy={busy || overwrite.loading || !canOverwrite}
215 onChange={(value) => setStance('user', userId, value)}
216 />
217 )
218 })}
219 </div>
220 {!perms.has(PERM.members) ? (
221 <AddUserOverride
222 spaceName={spaceName}
223 dirId={active}
224 busy={busy}
225 disabled={!canOverwrite || overwrite.loading}
226 current={overwrite.data?.users ?? []}
227 onApplied={() => setRevision((r) => r + 1)}
228 />
229 ) : null}
230 </>
231 )}
232 {notice ? (
233 <div className="mt-5">
234 <Alert tone="success">{notice}</Alert>
235 </div>
236 ) : null}
237 </section>
238 )
239}
240
241/** Keyed by node so switching nodes cannot carry the previous name over. */
242function RenameField({
243 spaceName,
244 nodeId,
245 current,
246 busy,
247 run,
248 onRenamed,
249}: {
250 spaceName: string
251 nodeId: string
252 current: string
253 busy: boolean
254 run: ReturnType<typeof useSave>['run']
255 onRenamed: () => void
256}) {
257 const { t } = useT()
258 const [name, setName] = useState(current)
259 return (
260 <div className="settings-subpanel">
261 <Field label={t('space.channelPermissions.rename')} hint={t('space.channelPermissions.renameHint')}>
262 <div className="flex flex-wrap items-center gap-2">
263 <input className={inputCls} maxLength={64} value={name} onChange={(e) => setName(e.target.value)} />
264 <Button
265 variant="secondary"
266 className="h-9 text-[12.5px]"
267 disabled={busy || !name.trim() || name.trim() === current}
268 onClick={() =>
269 void run(async () => {
270 await renameSpaceDir(spaceName, nodeId, name.trim())
271 onRenamed()
272 }, t('space.channelPermissions.renamed'))
273 }
274 >
275 {t('space.channelPermissions.saveName')}
276 </Button>
277 </div>
278 </Field>
279 </div>
280 )
281}
282
283function StanceRow({
284 title,
285 meta,
286 stance,
287 busy,
288 onChange,
289}: {
290 title: string
291 meta: string
292 stance: Stance
293 busy: boolean
294 onChange: (value: Stance) => void
295}) {
296 const { t } = useT()
297 return (
298 <div className="settings-card">
299 <SettingRow
300 title={title}
301 meta={meta}
302 actions={
303 <span
304 className="settings-segment-group"
305 role="group"
306 aria-label={t('space.channelPermissions.visibility', { title })}
307 >
308 {STANCES.map((option) => (
309 <button
310 key={option.value}
311 type="button"
312 disabled={busy}
313 aria-pressed={stance === option.value}
314 className="settings-segment"
315 onClick={() => option.value !== stance && onChange(option.value)}
316 >
317 {option.label}
318 </button>
319 ))}
320 </span>
321 }
322 />
323 </div>
324 )
325}
326
327/**
328 * Fallback for holders of `manage_overwrites` without `manage_members`: they may
329 * name a user, and the row is written once the username resolves.
330 */
331function AddUserOverride({
332 spaceName,
333 dirId,
334 busy,
335 disabled,
336 current,
337 onApplied,
338}: {
339 spaceName: string
340 dirId: string
341 busy: boolean
342 disabled: boolean
343 current: { user_id: number }[]
344 onApplied: () => void
345}) {
346 const { t } = useT()
347 const [handle, setHandle] = useState('')
348 const { run, busy: rowBusy, notice, error } = useSave()
349 return (
350 <div className="settings-card mt-3">
351 <div className="settings-subpanel is-flat">
352 <Field
353 label={t('space.channelPermissions.addByUsername')}
354 hint={t('space.channelPermissions.addByUsernameHint')}
355 >
356 <div className="flex flex-wrap items-center gap-2">
357 <input className={inputCls} placeholder="ada" value={handle} onChange={(e) => setHandle(e.target.value)} />
358 <Button
359 variant="secondary"
360 className="h-9 text-[12.5px]"
361 disabled={busy || rowBusy || disabled || !handle.trim()}
362 onClick={() =>
363 void run(async () => {
364 const found = await findUserByUsername(handle.trim())
365 if (current.some((row) => row.user_id === found.user_id)) {
366 throw new Error(t('space.channelPermissions.alreadyExceptional'))
367 }
368 await setDirUserOverwrite(spaceName, dirId, found.user_id, {
369 allow_mask: PERM_DIR_VISIBLE,
370 deny_mask: 0,
371 })
372 setHandle('')
373 onApplied()
374 }, t('space.channelPermissions.exceptionAdded'))
375 }
376 >
377 {t('space.channelPermissions.addVisible')}
378 </Button>
379 </div>
380 </Field>
381 {notice ? (
382 <div className="mt-3">
383 <Alert tone="success">{notice}</Alert>
384 </div>
385 ) : null}
386 {error ? (
387 <div className="mt-3">
388 <Alert tone="error">{error}</Alert>
389 </div>
390 ) : null}
391 </div>
392 </div>
393 )
394}
Added view/app/src/domains/space/management/ui/space-settings/cleanup-section.tsx view/app/src/domains/space/management/ui/space-settings/cleanup-section.tsx +243 −0 View view/app/src/domains/space/management/ui/space-settings/cleanup-section.tsx
@@ -0,0 +1,243 @@
1/**
2 * 对象清理(危险区,仅空间所有者)。
3 *
4 * 一张扁平清单覆盖空间里的两棵树:目录节点(目录 / 仓库 / 问题集 / 智能体集)
5 * 与频道(文字 / 语音 / 舞台 …)。每行给出与类型匹配的危险操作:
6 * 仓库可切换公开/私有并归档,频道可切换公开/私有或删除,问题集/智能体集可删除,
7 * 空目录可删除,带可见性覆盖的节点可一键恢复默认。删除一律走各模块自己的清理
8 * 逻辑(见 webapi::cleanup),所以这里只负责把危险讲清楚:每个破坏性动作都要
9 * 在确认框里报出对象路径。
10 */
11
12import { Alert } from '@app/components'
13import { EmptyNote, inputCls, SectionTitle, SettingRow, useSave } from '@app/domains/shared'
14import { type MessageKey, useT } from '@app/i18n'
15import { spaceAdminKey, useAsync } from '@app/lib'
16import { Button } from '@app/ui'
17import { useMemo, useState } from 'react'
18import {
19 type CleanupObject,
20 clearCleanupOverwrites,
21 deleteCleanupObject,
22 listCleanupObjects,
23 setCleanupArchived,
24 setCleanupVisibility,
25} from '../../model/space-admin'
26import type { SpaceSectionProps } from './props'
27
28const KIND_KEYS: Record<string, MessageKey> = {
29 dir: 'space.cleanup.kind.dir',
30 object: 'space.cleanup.kind.object',
31 repo: 'space.cleanup.kind.repo',
32 board: 'space.cleanup.kind.board',
33 text: 'space.cleanup.kind.text',
34 voice: 'space.cleanup.kind.voice',
35 stage: 'space.cleanup.kind.stage',
36 category: 'space.cleanup.kind.category',
37 news: 'space.cleanup.kind.news',
38 guild_room: 'space.cleanup.kind.guildRoom',
39 thread: 'space.cleanup.kind.thread',
40}
41
42/** 支持公开/私有的类型:仓库与频道。 */
43const VISIBILITY_KINDS = new Set(['repo', 'text', 'voice', 'stage', 'category', 'news', 'guild_room', 'thread'])
44/** 可以删除的类型(仓库除外,它只能归档)。 */
45const DELETE_KINDS = new Set([
46 'board',
47 'text',
48 'voice',
49 'stage',
50 'category',
51 'news',
52 'guild_room',
53 'thread',
54 'dir',
55 'object',
56])
57/** 可见性覆盖存放在目录节点上。 */
58const OVERWRITE_KINDS = new Set(['dir', 'object', 'repo', 'board'])
59
60export function CleanupSection({ spaceName, isOwner }: SpaceSectionProps) {
61 const { t } = useT()
62 const [tick, setTick] = useState(0)
63 const [query, setQuery] = useState('')
64 const [acting, setActing] = useState<string | null>(null)
65 const { run, busy, notice, error } = useSave()
66 const state = useAsync(() => listCleanupObjects(spaceName), [spaceName, tick], {
67 cacheKey: spaceAdminKey(spaceName, 'cleanup'),
68 })
69
70 const filtered = useMemo(() => {
71 const list = state.data ?? []
72 const needle = query.trim().toLowerCase()
73 if (!needle) return list
74 return list.filter((item) => `${item.name} ${item.path} ${item.kind}`.toLowerCase().includes(needle))
75 }, [state.data, query])
76
77 if (!isOwner) {
78 return (
79 <section>
80 <SectionTitle title={t('space.settings.sections.cleanup')} sub={t('space.cleanup.ownerSub')} />
81 <Alert tone="info">{t('space.cleanup.notOwner')}</Alert>
82 </section>
83 )
84 }
85
86 const refresh = () => setTick((value) => value + 1)
87
88 const act = (key: string, fn: () => Promise<unknown>, ok: string) => {
89 setActing(key)
90 void run(fn, ok).finally(() => setActing(null))
91 }
92
93 return (
94 <section>
95 <SectionTitle title={t('space.settings.sections.cleanup')} sub={t('space.cleanup.sub')} />
96 {notice && !error ? <Alert tone="success">{notice}</Alert> : null}
97 {error ? <Alert tone="error">{error}</Alert> : null}
98
99 <div className="mt-4 max-w-[420px]">
100 <input
101 className={inputCls}
102 value={query}
103 placeholder={t('space.cleanup.filterPlaceholder')}
104 onChange={(event) => setQuery(event.target.value)}
105 />
106 </div>
107
108 {state.loading && !state.data ? (
109 <EmptyNote>{t('ui.loading')}</EmptyNote>
110 ) : filtered.length === 0 ? (
111 <EmptyNote>{t('space.cleanup.empty')}</EmptyNote>
112 ) : (
113 <div className="mt-4 space-y-2">
114 {filtered.map((item) => (
115 <CleanupRow
116 key={`${item.kind}:${item.id}`}
117 spaceName={spaceName}
118 item={item}
119 busy={busy || acting === `${item.kind}:${item.id}`}
120 onAct={act}
121 onChanged={refresh}
122 />
123 ))}
124 </div>
125 )}
126 </section>
127 )
128}
129
130function CleanupRow({
131 spaceName,
132 item,
133 busy,
134 onAct,
135 onChanged,
136}: {
137 spaceName: string
138 item: CleanupObject
139 busy: boolean
140 onAct: (key: string, fn: () => Promise<unknown>, ok: string) => void
141 onChanged: () => void
142}) {
143 const { t } = useT()
144 const rowKey = `${item.kind}:${item.id}`
145 const hasChildren = item.child_count > 0
146
147 const toggleVisibility = () =>
148 onAct(
149 rowKey,
150 async () => {
151 await setCleanupVisibility(spaceName, item.kind, item.id, !item.is_private)
152 onChanged()
153 },
154 t('space.cleanup.updated'),
155 )
156
157 const toggleArchive = () =>
158 onAct(
159 rowKey,
160 async () => {
161 await setCleanupArchived(spaceName, item.kind, item.id, !item.is_archived)
162 onChanged()
163 },
164 t('space.cleanup.updated'),
165 )
166
167 const remove = () => {
168 if (!window.confirm(t('space.cleanup.confirmDelete', { name: item.path || item.name }))) return
169 onAct(
170 rowKey,
171 async () => {
172 await deleteCleanupObject(spaceName, item.kind, item.id)
173 onChanged()
174 },
175 t('space.cleanup.deleted'),
176 )
177 }
178
179 const clearOverwrites = () => {
180 if (!window.confirm(t('space.cleanup.confirmClearOverwrites', { name: item.path || item.name }))) return
181 onAct(
182 rowKey,
183 async () => {
184 await clearCleanupOverwrites(spaceName, item.kind, item.id)
185 onChanged()
186 },
187 t('space.cleanup.overwritesCleared'),
188 )
189 }
190
191 const meta: string[] = [item.path]
192 if (item.kind === 'dir' || item.kind === 'object') {
193 meta.push(t('space.cleanup.children', { count: item.child_count }))
194 }
195 if (item.overwrite_count > 0) {
196 meta.push(t('space.cleanup.overwrites', { count: item.overwrite_count }))
197 }
198 if (item.is_archived) meta.push(t('space.cleanup.archived'))
199
200 return (
201 <div className="settings-card">
202 <SettingRow
203 lead={
204 <span className="rounded-md bg-shell-active px-2 py-0.5 text-[11px] font-semibold text-shell-ink">
205 {t(KIND_KEYS[item.kind] ?? 'space.cleanup.kind.object')}
206 </span>
207 }
208 title={item.name}
209 meta={meta.join(' · ')}
210 actions={
211 <>
212 {VISIBILITY_KINDS.has(item.kind) ? (
213 <Button variant="secondary" className="h-8 text-[12.5px]" disabled={busy} onClick={toggleVisibility}>
214 {item.is_private ? t('space.cleanup.makePublic') : t('space.cleanup.makePrivate')}
215 </Button>
216 ) : null}
217 {item.kind === 'repo' ? (
218 <Button variant="secondary" className="h-8 text-[12.5px]" disabled={busy} onClick={toggleArchive}>
219 {item.is_archived ? t('space.cleanup.unarchive') : t('space.cleanup.archive')}
220 </Button>
221 ) : null}
222 {OVERWRITE_KINDS.has(item.kind) && item.overwrite_count > 0 ? (
223 <Button variant="secondary" className="h-8 text-[12.5px]" disabled={busy} onClick={clearOverwrites}>
224 {t('space.cleanup.clearOverwrites')}
225 </Button>
226 ) : null}
227 {DELETE_KINDS.has(item.kind) ? (
228 <Button
229 variant="ghost"
230 className="settings-danger-btn h-8 text-[12.5px]"
231 disabled={busy || hasChildren}
232 title={hasChildren ? t('space.cleanup.deleteBlocked') : undefined}
233 onClick={remove}
234 >
235 {t('common.action.delete')}
236 </Button>
237 ) : null}
238 </>
239 }
240 />
241 </div>
242 )
243}
Added view/app/src/domains/space/management/ui/space-settings/danger-section.tsx view/app/src/domains/space/management/ui/space-settings/danger-section.tsx +65 −0 View view/app/src/domains/space/management/ui/space-settings/danger-section.tsx
@@ -0,0 +1,65 @@
1import { Alert } from '@app/components'
2import { Field, inputCls, SectionTitle, useSave } from '@app/domains/shared'
3import { useT } from '@app/i18n'
4import { Button } from '@app/ui'
5import { useState } from 'react'
6import { deleteSpace } from '../../model/space-admin'
7import type { SpaceSectionProps } from './props'
8
9/** Owner-only teardown, kept away from every other form on purpose. */
10export function DangerSection({ spaceName, space, isOwner, onDeleted }: SpaceSectionProps & { onDeleted: () => void }) {
11 const { t } = useT()
12 const [confirm, setConfirm] = useState('')
13 const [armed, setArmed] = useState(false)
14 const { busy, error, run } = useSave()
15
16 if (!isOwner) {
17 return (
18 <section>
19 <SectionTitle title={t('space.settings.danger')} sub={t('space.danger.ownerSub')} />
20 <Alert tone="info">{t('space.danger.notOwner')}</Alert>
21 </section>
22 )
23 }
24
25 return (
26 <section>
27 <SectionTitle title={t('space.settings.danger')} sub={t('space.danger.sub')} />
28 <div className="settings-card is-danger">
29 <p className="text-[13.5px] font-semibold">{t('space.danger.deleteName', { name: space.name })}</p>
30 <p className="mt-1 text-[12.5px] text-shell-ink-muted">
31 {t('space.danger.description', { count: space.members_count })}
32 </p>
33 {!armed ? (
34 <Button className="settings-danger-btn mt-4 h-9" disabled={busy} onClick={() => setArmed(true)}>
35 {t('space.settings.deleteSpace')}
36 </Button>
37 ) : (
38 <div className="mt-4">
39 <Field label={t('space.danger.confirmLabel', { name: space.name })}>
40 <input
41 className={inputCls}
42 value={confirm}
43 placeholder={space.name}
44 onChange={(e) => setConfirm(e.target.value)}
45 />
46 </Field>
47 <div className="flex flex-wrap items-center gap-3">
48 <Button
49 className="settings-danger-btn h-9"
50 disabled={busy || confirm.trim().toLowerCase() !== spaceName.toLowerCase()}
51 onClick={() => void run(() => deleteSpace(spaceName).then(onDeleted))}
52 >
53 {busy ? t('space.danger.deleting') : t('space.danger.confirm')}
54 </Button>
55 <Button variant="ghost" className="h-9" disabled={busy} onClick={() => setArmed(false)}>
56 {t('common.action.cancel')}
57 </Button>
58 {error ? <Alert tone="error">{error}</Alert> : null}
59 </div>
60 </div>
61 )}
62 </div>
63 </section>
64 )
65}
Added view/app/src/domains/space/management/ui/space-settings/general-section.tsx view/app/src/domains/space/management/ui/space-settings/general-section.tsx +168 −0 View view/app/src/domains/space/management/ui/space-settings/general-section.tsx
@@ -0,0 +1,168 @@
1import { Alert } from '@app/components'
2import { CopyButton, Field, fmtDate, inputCls, SaveBar, SectionTitle, Toggle, useSave } from '@app/domains/shared'
3import { useT } from '@app/i18n'
4import { useState } from 'react'
5import { updateSpace } from '../../model/api'
6import { JOIN_LEVEL_OPTIONS, slug } from './meta'
7import type { SpaceSectionProps } from './props'
8
9type Draft = {
10 name: string
11 description: string
12 welcome_message: string
13 is_private: boolean
14 allow_join: boolean
15 allow_join_level: string
16 allow_invite: boolean
17 member_must_enable_2fa: boolean
18}
19
20const draftOf = (props: SpaceSectionProps): Draft => ({
21 name: props.space.name,
22 description: props.space.description ?? '',
23 welcome_message: props.space.welcome_message ?? '',
24 is_private: props.space.is_private,
25 allow_join: props.space.allow_join,
26 allow_join_level: props.space.allow_join_level,
27 allow_invite: props.space.allow_invite,
28 member_must_enable_2fa: props.space.member_must_enable_2fa,
29})
30
31/** Identity, description and the join/visibility switches. Owner may write. */
32export function GeneralSection(props: SpaceSectionProps) {
33 const { t } = useT()
34 const { space, isOwner, onSpaceUpdated } = props
35 // Read once per open: the modal unmounts a section when you leave it, so
36 // reopening always picks up whatever the server holds at that moment.
37 const [draft, setDraft] = useState(() => draftOf(props))
38 const { busy, notice, error, run } = useSave()
39
40 const set = <K extends keyof Draft>(key: K, value: Draft[K]) => {
41 setDraft((prev) => ({ ...prev, [key]: value }))
42 }
43
44 const save = () =>
45 void run(async () => {
46 const nextName = draft.name.trim()
47 await updateSpace(space.name, {
48 ...(nextName && nextName !== space.name ? { name: nextName } : {}),
49 description: draft.description.trim() || null,
50 welcome_message: draft.welcome_message.trim() || null,
51 allow_join: draft.allow_join,
52 allow_join_level: Number(draft.allow_join_level) || 0,
53 allow_invite: draft.allow_invite,
54 member_must_enable_2fa: draft.member_must_enable_2fa,
55 })
56 onSpaceUpdated()
57 }, t('space.general.saved'))
58
59 return (
60 <section>
61 <SectionTitle title={t('space.general.title')} sub={t('space.general.sub')} />
62 {!isOwner ? <Alert tone="info">{t('space.general.ownerOnly')}</Alert> : null}
63 <Field label={t('space.general.name')} hint={t('space.general.nameHint')}>
64 <input
65 className={inputCls}
66 value={draft.name}
67 maxLength={64}
68 disabled={!isOwner}
69 onChange={(e) => set('name', slug(e.target.value))}
70 />
71 </Field>
72 <Field label={t('space.general.description')} hint={t('space.general.descriptionHint')}>
73 <textarea
74 className={`${inputCls} h-auto resize-none py-2 leading-relaxed`}
75 rows={3}
76 maxLength={512}
77 disabled={!isOwner}
78 value={draft.description}
79 onChange={(e) => set('description', e.target.value)}
80 />
81 </Field>
82 <Field label={t('space.general.welcome')} hint={t('space.general.welcomeHint')}>
83 <textarea
84 className={`${inputCls} h-auto resize-none py-2 leading-relaxed`}
85 rows={2}
86 maxLength={512}
87 disabled={!isOwner}
88 value={draft.welcome_message}
89 placeholder={t('space.general.welcomePlaceholder')}
90 onChange={(e) => set('welcome_message', e.target.value)}
91 />
92 </Field>
93
94 <h3 className="mb-3 mt-8 text-[14px] font-bold">{t('space.general.access')}</h3>
95 <Toggle
96 checked={draft.is_private}
97 onChange={(v) => set('is_private', v)}
98 label={t('space.general.private')}
99 disabled={!isOwner}
100 />
101 <Toggle
102 checked={draft.allow_join}
103 onChange={(v) => set('allow_join', v)}
104 label={t('space.general.allowJoin')}
105 disabled={!isOwner}
106 />
107 <Field label={t('space.general.joinLevel')}>
108 <select
109 className={inputCls}
110 disabled={!isOwner || !draft.allow_join}
111 value={draft.allow_join_level}
112 onChange={(e) => set('allow_join_level', e.target.value)}
113 >
114 {JOIN_LEVEL_OPTIONS.map((o) => (
115 <option key={o.value} value={o.value}>
116 {o.label}
117 </option>
118 ))}
119 </select>
120 </Field>
121 <Toggle
122 checked={draft.allow_invite}
123 onChange={(v) => set('allow_invite', v)}
124 label={t('space.general.allowInvite')}
125 disabled={!isOwner}
126 />
127 <Toggle
128 checked={draft.member_must_enable_2fa}
129 onChange={(v) => set('member_must_enable_2fa', v)}
130 label={t('space.general.require2fa')}
131 disabled={!isOwner}
132 />
133
134 <div className="mt-8">
135 <SaveBar busy={busy} notice={notice} error={error} onSave={save} label={t('space.general.save')} />
136 </div>
137
138 <h3 className="mb-3 mt-10 text-[14px] font-bold">{t('space.general.readonly')}</h3>
139 <dl className="settings-facts">
140 <div>
141 <dt>{t('space.general.id')}</dt>
142 <dd>
143 <code>{space.id}</code>
144 <CopyButton text={space.id} />
145 </dd>
146 </div>
147 <div>
148 <dt>{t('space.general.owner')}</dt>
149 <dd>{space.owner?.display_name || space.owner?.username || '—'}</dd>
150 </div>
151 <div>
152 <dt>{t('space.general.members')}</dt>
153 <dd>{space.members_count}</dd>
154 </div>
155 <div>
156 <dt>{t('space.general.status')}</dt>
157 <dd>{space.is_active ? t('space.general.active') : t('space.general.inactive')}</dd>
158 </div>
159 <div>
160 <dt>{t('space.general.createdUpdated')}</dt>
161 <dd>
162 {fmtDate(space.created_at)} · {fmtDate(space.updated_at)}
163 </dd>
164 </div>
165 </dl>
166 </section>
167 )
168}
Added view/app/src/domains/space/management/ui/space-settings/images-section.tsx view/app/src/domains/space/management/ui/space-settings/images-section.tsx +198 −0 View view/app/src/domains/space/management/ui/space-settings/images-section.tsx
@@ -0,0 +1,198 @@
1import { Alert } from '@app/components'
2import { fmtDate, SectionTitle, useSave } from '@app/domains/shared'
3import { useT } from '@app/i18n'
4import { spaceAdminKey, spaceImageKey, useAsync } from '@app/lib'
5import { invalidateCache } from '@app/services/storage'
6import { Button } from '@app/ui'
7import { useRef, useState } from 'react'
8import { deleteSpaceImage, getSpaceImage, type SpaceImage, uploadSpaceImage } from '../../model/space-admin'
9import { IMAGE_KINDS } from './meta'
10import type { SpaceSectionProps } from './props'
11
12const MAX_BYTES = 5 * 1024 * 1024
13
14/** The four images a space can carry, each behind `/{kind}` routes. */
15export function ImagesSection({ spaceName, onSpaceUpdated }: SpaceSectionProps) {
16 const { t } = useT()
17 const [tick, setTick] = useState(0)
18 const images = useAsync(
19 async () => {
20 const settled = await Promise.allSettled(IMAGE_KINDS.map((item) => getSpaceImage(spaceName, item.kind)))
21 return Object.fromEntries(
22 IMAGE_KINDS.map((item, index) => {
23 const result = settled[index]
24 return [item.kind, result?.status === 'fulfilled' ? result.value : null]
25 }),
26 ) as Record<string, SpaceImage | null>
27 },
28 [spaceName, tick],
29 { cacheKey: spaceAdminKey(spaceName, 'images') },
30 )
31
32 return (
33 <section>
34 <SectionTitle title={t('space.settings.sections.images')} sub={t('space.images.sub')} />
35 {images.error ? (
36 <div className="mb-4">
37 <Alert tone="error">{t('space.images.loadFailed')}</Alert>
38 </div>
39 ) : null}
40 <div className="divide-y divide-shell-hairline">
41 {IMAGE_KINDS.map((item) => (
42 <ImageRow
43 key={item.kind}
44 spaceName={spaceName}
45 kind={item.kind}
46 label={item.label}
47 hint={item.hint}
48 image={images.data?.[item.kind] ?? null}
49 loading={images.loading}
50 onSaved={() => setTick((t) => t + 1)}
51 onSpaceUpdated={onSpaceUpdated}
52 />
53 ))}
54 </div>
55 </section>
56 )
57}
58
59function ImageRow({
60 spaceName,
61 kind,
62 label,
63 hint,
64 image,
65 loading,
66 onSaved,
67 onSpaceUpdated,
68}: {
69 spaceName: string
70 kind: 'avatar' | 'banner' | 'invite_banner' | 'embed_splash'
71 label: string
72 hint: string
73 image: SpaceImage | null
74 loading: boolean
75 onSaved: () => void
76 onSpaceUpdated: () => void
77}) {
78 const { t } = useT()
79 const inputRef = useRef<HTMLInputElement>(null)
80 const { busy, notice, error, run } = useSave()
81
82 const pick = (file: File) =>
83 void run(
84 async () => {
85 if (file.size > MAX_BYTES) throw new Error(t('space.images.tooLarge'))
86 const size = await readImageSize(file)
87 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))
91 onSaved()
92 // The header avatar and the rail tile read the space row, not this call.
93 onSpaceUpdated()
94 },
95 t('space.images.updated', { label }),
96 )
97
98 const round = kind === 'avatar'
99 const meta = image
100 ? `${image.format} · ${Math.round(image.bytes / 1024)} KB${
101 image.width && image.height ? ` · ${image.width}×${image.height}` : ''
102 } · ${fmtDate(image.updated_at)}`
103 : hint
104
105 return (
106 <div className="grid gap-4 py-5 md:grid-cols-[minmax(0,1fr)_minmax(0,1.05fr)] md:gap-10">
107 <div className="min-w-0">
108 <p className="text-[14px] font-semibold">{label}</p>
109 <p className="mt-1 text-[12px] text-shell-ink-muted">{meta}</p>
110 <div className="mt-3 flex flex-wrap items-center gap-2">
111 <input
112 ref={inputRef}
113 type="file"
114 accept="image/png,image/jpeg,image/webp,image/gif"
115 className="hidden"
116 onChange={(e) => {
117 const file = e.target.files?.[0]
118 if (file) pick(file)
119 e.target.value = ''
120 }}
121 />
122 <Button
123 variant="primary"
124 className="h-9 px-3.5 text-[13px]"
125 disabled={busy}
126 onClick={() => inputRef.current?.click()}
127 >
128 {image ? t('space.images.replace') : t('common.action.upload')}
129 </Button>
130 {image ? (
131 <Button
132 variant="ghost"
133 className="h-9 text-[13px]"
134 disabled={busy}
135 onClick={() =>
136 void run(
137 async () => {
138 await deleteSpaceImage(spaceName, kind)
139 invalidateCache(spaceImageKey(spaceName, kind))
140 onSaved()
141 onSpaceUpdated()
142 },
143 t('space.images.deleted', { label }),
144 )
145 }
146 >
147 {t('common.action.delete')}
148 </Button>
149 ) : null}
150 </div>
151 {notice || error ? (
152 <div className="mt-3">
153 {error ? <Alert tone="error">{error}</Alert> : <Alert tone="success">{notice}</Alert>}
154 </div>
155 ) : null}
156 </div>
157 <div className="min-w-0">
158 {image?.url ? (
159 <img
160 src={image.url}
161 alt=""
162 className={
163 round
164 ? 'h-20 w-20 rounded-full border border-shell-hairline object-cover'
165 : 'max-h-[190px] w-full rounded-xl border border-shell-hairline object-cover'
166 }
167 />
168 ) : (
169 <span
170 aria-hidden
171 className={`grid place-items-center border border-dashed border-shell-hairline bg-shell-field text-[12.5px] text-shell-ink-muted ${
172 round ? 'h-20 w-20 rounded-full' : 'h-[120px] w-full rounded-xl'
173 }`}
174 >
175 {loading ? t('space.images.loading') : t('space.images.unset')}
176 </span>
177 )}
178 </div>
179 </div>
180 )
181}
182
183/** Best-effort intrinsic size; the backend records it but does not require it. */
184function readImageSize(file: File): Promise<{ width: number; height: number } | null> {
185 return new Promise((resolve) => {
186 const url = URL.createObjectURL(file)
187 const img = new Image()
188 img.onload = () => {
189 URL.revokeObjectURL(url)
190 resolve({ width: img.naturalWidth, height: img.naturalHeight })
191 }
192 img.onerror = () => {
193 URL.revokeObjectURL(url)
194 resolve(null)
195 }
196 img.src = url
197 })
198}
Added view/app/src/domains/space/management/ui/space-settings/join-requests-section.tsx view/app/src/domains/space/management/ui/space-settings/join-requests-section.tsx +182 −0 View view/app/src/domains/space/management/ui/space-settings/join-requests-section.tsx
@@ -0,0 +1,182 @@
1import { Alert } from '@app/components'
2import { Chip, EmptyNote, fmtDate, InitialAvatar, SectionTitle, useSave } from '@app/domains/shared'
3import { useT } from '@app/i18n'
4import { spaceAdminKey, useAsync } from '@app/lib'
5import { Button } from '@app/ui'
6import { Loader2 } from 'lucide-react'
7import { useState } from 'react'
8import {
9 approveSpaceJoinRequest,
10 listSpaceJoinRequests,
11 rejectSpaceJoinRequest,
12 type SpaceJoinRequest,
13} from '../../model/space-admin'
14import { userLabel } from './meta'
15import type { SpaceSectionProps } from './props'
16
17const PAGE_SIZE = 25
18
19type Filter = 'pending' | 'approved' | 'rejected' | 'all'
20
21const FILTERS: {
22 id: Filter
23 label:
24 | 'space.joinRequests.filter.pending'
25 | 'space.joinRequests.filter.approved'
26 | 'space.joinRequests.filter.rejected'
27 | 'space.joinRequests.filter.all'
28}[] = [
29 { id: 'pending', label: 'space.joinRequests.filter.pending' },
30 { id: 'approved', label: 'space.joinRequests.filter.approved' },
31 { id: 'rejected', label: 'space.joinRequests.filter.rejected' },
32 { id: 'all', label: 'space.joinRequests.filter.all' },
33]
34
35const STATUS_KEYS = {
36 pending: 'space.joinRequests.status.pending',
37 approved: 'space.joinRequests.status.approved',
38 rejected: 'space.joinRequests.status.rejected',
39 withdrawn: 'space.joinRequests.status.withdrawn',
40} as const
41
42/** The approval queue: who asked to join, their note, and approve/reject. */
43export function JoinRequestsSection({ spaceName }: SpaceSectionProps) {
44 const { t } = useT()
45 const [filter, setFilter] = useState<Filter>('pending')
46 const [limit, setLimit] = useState(PAGE_SIZE)
47 const [tick, setTick] = useState(0)
48 const [pendingId, setPendingId] = useState<string | null>(null)
49 const { notice, error, run } = useSave()
50 const page = useAsync(
51 () =>
52 listSpaceJoinRequests(spaceName, {
53 status: filter === 'all' ? undefined : filter,
54 limit,
55 offset: 0,
56 }),
57 [spaceName, filter, limit, tick],
58 { cacheKey: spaceAdminKey(spaceName, `join-requests:${filter}`) },
59 )
60 const rows = page.data?.items ?? []
61 const total = page.data?.total ?? 0
62
63 const decide = (row: SpaceJoinRequest, approve: boolean) => {
64 const name = row.user?.display_name || row.user?.username || ''
65 setPendingId(row.id)
66 void run(
67 async () => {
68 await (approve ? approveSpaceJoinRequest(spaceName, row.id) : rejectSpaceJoinRequest(spaceName, row.id))
69 setTick((value) => value + 1)
70 },
71 t(approve ? 'space.joinRequests.approved' : 'space.joinRequests.rejected', { name }),
72 ).finally(() => setPendingId(null))
73 }
74
75 return (
76 <section>
77 <SectionTitle title={t('space.joinRequests.title')} sub={t('space.joinRequests.sub')} />
78
79 <div className="mb-4 flex flex-wrap items-center gap-2">
80 {FILTERS.map((item) => (
81 <button
82 key={item.id}
83 type="button"
84 aria-pressed={filter === item.id}
85 onClick={() => {
86 setFilter(item.id)
87 setLimit(PAGE_SIZE)
88 }}
89 className={`rounded-field border border-shell-hairline px-3 py-1.5 text-[12.5px] transition-colors ${
90 filter === item.id ? 'bg-shell-active font-semibold' : 'hover:bg-shell-field'
91 }`}
92 >
93 {t(item.label)}
94 </button>
95 ))}
96 <span className="ml-auto text-[12px] text-shell-ink-muted">
97 {t('space.joinRequests.count', { count: total })}
98 </span>
99 </div>
100
101 {notice ? (
102 <div className="mb-4">
103 <Alert tone="success">{notice}</Alert>
104 </div>
105 ) : null}
106 {error ? (
107 <div className="mb-4">
108 <Alert tone="error">{error}</Alert>
109 </div>
110 ) : null}
111
112 {page.loading && !page.data ? (
113 <EmptyNote>{t('ui.loading')}</EmptyNote>
114 ) : page.error && !page.data ? (
115 <Alert tone="error">{t('space.joinRequests.loadFailed')}</Alert>
116 ) : rows.length === 0 ? (
117 <div className="py-6 text-center">
118 <EmptyNote>{t('space.joinRequests.empty')}</EmptyNote>
119 <p className="mt-1 text-[12.5px] text-shell-ink-muted">{t('space.joinRequests.emptyHint')}</p>
120 </div>
121 ) : (
122 <div className="space-y-2">
123 {rows.map((row) => (
124 <div key={row.id} className="settings-card p-3.5">
125 <div className="mb-2 flex items-center gap-3">
126 <InitialAvatar name={row.user?.display_name || row.user?.username || '?'} url={row.user?.avatar_url} />
127 <div className="min-w-0 flex-1">
128 <p className="truncate text-[13.5px] font-semibold">{userLabel(row.user)}</p>
129 <p className="text-[11.5px] text-shell-ink-muted">
130 {t('space.joinRequests.requestedAt', { time: fmtDate(row.created_at) })}
131 </p>
132 </div>
133 <Chip>{t(STATUS_KEYS[row.status])}</Chip>
134 </div>
135 <p className="whitespace-pre-wrap break-words text-[12.5px] leading-[1.5]">
136 {row.note ? row.note : <span className="text-shell-ink-muted">{t('space.joinRequests.noNote')}</span>}
137 </p>
138 {row.decided_by ? (
139 <p className="mt-1.5 text-[11.5px] text-shell-ink-muted">
140 {t('space.joinRequests.decidedBy', { name: userLabel(row.decided_by) })}
141 {row.decided_at ? ` · ${fmtDate(row.decided_at)}` : ''}
142 </p>
143 ) : null}
144 {row.status === 'pending' ? (
145 <div className="mt-3 flex items-center gap-2">
146 <Button
147 className="h-8 text-[12.5px]"
148 disabled={pendingId === row.id}
149 onClick={() => decide(row, true)}
150 >
151 {pendingId === row.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden /> : null}
152 {t('space.joinRequests.approve')}
153 </Button>
154 <Button
155 variant="secondary"
156 className="h-8 text-[12.5px]"
157 disabled={pendingId === row.id}
158 onClick={() => decide(row, false)}
159 >
160 {t('space.joinRequests.reject')}
161 </Button>
162 </div>
163 ) : null}
164 </div>
165 ))}
166 {rows.length < total ? (
167 <div className="flex justify-center pt-2">
168 <Button
169 variant="secondary"
170 className="h-9 text-[12.5px]"
171 disabled={page.loading}
172 onClick={() => setLimit((value) => value + PAGE_SIZE)}
173 >
174 {t('space.joinRequests.loadMore')}
175 </Button>
176 </div>
177 ) : null}
178 </div>
179 )}
180 </section>
181 )
182}
Added view/app/src/domains/space/management/ui/space-settings/join-rules-section.tsx view/app/src/domains/space/management/ui/space-settings/join-rules-section.tsx +210 −0 View view/app/src/domains/space/management/ui/space-settings/join-rules-section.tsx
@@ -0,0 +1,210 @@
1import { Alert } from '@app/components'
2import {
3 Chip,
4 EmptyNote,
5 Field,
6 fmtDate,
7 inputCls,
8 SectionTitle,
9 SettingRow,
10 Toggle,
11 useSave,
12} from '@app/domains/shared'
13import { useT } from '@app/i18n'
14import { spaceAdminKey, useAsync } from '@app/lib'
15import { Button } from '@app/ui'
16import { useState } from 'react'
17import {
18 createSpaceJoinRule,
19 deleteSpaceJoinRule,
20 listSpaceGroups,
21 listSpaceJoinRules,
22 type SpaceJoinRule,
23 updateSpaceJoinRule,
24} from '../../model/space-admin'
25import { JOIN_TRIGGER_OPTIONS } from './meta'
26import type { SpaceSectionProps } from './props'
27
28/**
29 * Automatic role grants: reacting with `matcher` on an issue (or an issue
30 * comment) adds the reactor to the rule's group.
31 */
32export function JoinRulesSection({ spaceName }: SpaceSectionProps) {
33 const { t } = useT()
34 const [tick, setTick] = useState(0)
35 const { busy, notice, error, run } = useSave()
36 const rules = useAsync(() => listSpaceJoinRules(spaceName), [spaceName, tick], {
37 cacheKey: spaceAdminKey(spaceName, 'join-rules'),
38 })
39 const groups = useAsync(() => listSpaceGroups(spaceName), [spaceName], {
40 cacheKey: spaceAdminKey(spaceName, 'groups'),
41 })
42 const [draft, setDraft] = useState({ role_id: '', trigger: JOIN_TRIGGER_OPTIONS[0].value, matcher: '' })
43 const refresh = () => setTick((t) => t + 1)
44 const groupName = (id: string) =>
45 groups.data?.find((group) => group.id === id)?.name ?? t('space.joinRules.deletedRole')
46
47 return (
48 <section>
49 <SectionTitle title={t('space.settings.sections.join')} sub={t('space.joinRules.sub')} />
50 {(groups.data ?? []).length === 0 ? (
51 <EmptyNote>{t('space.joinRules.needRole')}</EmptyNote>
52 ) : (
53 <div className="settings-card mb-6 p-4">
54 <div className="grid gap-x-4 sm:grid-cols-3">
55 <Field label={t('space.joinRules.grantRole')}>
56 <select
57 className={inputCls}
58 value={draft.role_id}
59 onChange={(e) => setDraft({ ...draft, role_id: e.target.value })}
60 >
61 <option value="">{t('space.joinRules.selectRole')}</option>
62 {groups.data!.map((group) => (
63 <option key={group.id} value={group.id}>
64 {group.name}
65 </option>
66 ))}
67 </select>
68 </Field>
69 <Field label={t('space.joinRules.trigger')}>
70 <select
71 className={inputCls}
72 value={draft.trigger}
73 onChange={(e) => setDraft({ ...draft, trigger: e.target.value })}
74 >
75 {JOIN_TRIGGER_OPTIONS.map((option) => (
76 <option key={option.value} value={option.value}>
77 {option.label}
78 </option>
79 ))}
80 </select>
81 </Field>
82 <Field label={t('space.joinRules.matcher')} hint={t('space.joinRules.matcherHint')}>
83 <input
84 className={inputCls}
85 value={draft.matcher}
86 maxLength={64}
87 placeholder="👍"
88 onChange={(e) => setDraft({ ...draft, matcher: e.target.value })}
89 />
90 </Field>
91 </div>
92 <div className="flex flex-wrap items-center gap-3">
93 <Button
94 className="h-9"
95 disabled={busy || !draft.role_id || !draft.matcher.trim()}
96 onClick={() =>
97 void run(async () => {
98 await createSpaceJoinRule(spaceName, {
99 role_id: draft.role_id,
100 trigger: draft.trigger as 'issue_reaction',
101 matcher: draft.matcher.trim(),
102 })
103 setDraft({ ...draft, matcher: '' })
104 refresh()
105 }, t('space.joinRules.created'))
106 }
107 >
108 {t('space.joinRules.create')}
109 </Button>
110 {error ? <Alert tone="error">{error}</Alert> : null}
111 {notice && !error ? <Alert tone="success">{notice}</Alert> : null}
112 </div>
113 </div>
114 )}
115
116 {rules.loading ? (
117 <EmptyNote>{t('space.joinRules.loading')}</EmptyNote>
118 ) : (rules.data ?? []).length === 0 ? (
119 <EmptyNote>{t('space.joinRules.empty')}</EmptyNote>
120 ) : (
121 <div className="space-y-2">
122 {rules.data!.map((rule) => (
123 <RuleRow
124 key={rule.id}
125 spaceName={spaceName}
126 rule={rule}
127 groupName={groupName(rule.role_id)}
128 busy={busy}
129 onSaved={refresh}
130 />
131 ))}
132 </div>
133 )}
134 </section>
135 )
136}
137
138function RuleRow({
139 spaceName,
140 rule,
141 groupName,
142 busy,
143 onSaved,
144}: {
145 spaceName: string
146 rule: SpaceJoinRule
147 groupName: string
148 busy: boolean
149 onSaved: () => void
150}) {
151 const [enabled, setEnabled] = useState(rule.enabled)
152 const [matcher, setMatcher] = useState(rule.matcher)
153 const { t } = useT()
154 const { run, busy: rowBusy, notice, error } = useSave()
155 const trigger = JOIN_TRIGGER_OPTIONS.find((option) => option.value === rule.trigger)?.label ?? rule.trigger
156
157 return (
158 <div className="settings-card">
159 <SettingRow
160 title={
161 <>
162 {trigger} · <code className="text-[12.5px]">{rule.matcher}</code> → {groupName}
163 </>
164 }
165 meta={t('space.roles.createdAt', { date: fmtDate(rule.created_at) })}
166 actions={<Chip>{rule.enabled ? t('space.joinRules.enabled') : t('space.joinRules.disabled')}</Chip>}
167 />
168 <div className="settings-subpanel">
169 <div className="grid gap-x-4 sm:grid-cols-2">
170 <Field label={t('space.joinRules.matcher')}>
171 <input className={inputCls} value={matcher} maxLength={64} onChange={(e) => setMatcher(e.target.value)} />
172 </Field>
173 <div className="pt-1">
174 <Toggle checked={enabled} onChange={setEnabled} label={t('space.joinRules.enable')} />
175 </div>
176 </div>
177 <div className="flex flex-wrap items-center gap-3">
178 <Button
179 variant="secondary"
180 className="h-9 text-[12.5px]"
181 disabled={busy || rowBusy || !matcher.trim()}
182 onClick={() =>
183 void run(async () => {
184 await updateSpaceJoinRule(spaceName, rule.id, { enabled, matcher: matcher.trim() })
185 onSaved()
186 }, t('space.joinRules.updated'))
187 }
188 >
189 {t('space.joinRules.save')}
190 </Button>
191 <Button
192 variant="ghost"
193 className="h-9 text-[12.5px]"
194 disabled={busy || rowBusy}
195 onClick={() =>
196 void run(async () => {
197 await deleteSpaceJoinRule(spaceName, rule.id)
198 onSaved()
199 }, t('space.joinRules.deleted'))
200 }
201 >
202 {t('common.action.delete')}
203 </Button>
204 {notice ? <Alert tone="success">{notice}</Alert> : null}
205 {error ? <Alert tone="error">{error}</Alert> : null}
206 </div>
207 </div>
208 </div>
209 )
210}
Added view/app/src/domains/space/management/ui/space-settings/meta.ts view/app/src/domains/space/management/ui/space-settings/meta.ts +215 −0 View view/app/src/domains/space/management/ui/space-settings/meta.ts
@@ -0,0 +1,215 @@
1/**
2 * Labels, option lists and small formatters shared by the space settings
3 * sections. Non-component module for the same reason as the user settings'
4 * helpers: react-refresh wants component-only modules next to JSX.
5 */
6import { type MessageKey, t } from '@app/i18n'
7import type { DirNode } from '../../model/api'
8import type { PublicUser } from '../../model/space-admin'
9
10/** Server-side gating per endpoint group (see `lib/space/src/share/authz.rs`). */
11export const PERM = {
12 members: 'manage_members',
13 roles: 'manage_roles',
14 invites: 'manage_invites',
15 bans: 'manage_bans',
16 channels: 'manage_channels',
17 overwrites: 'manage_overwrites',
18} as const
19
20export const MANAGE_PERMS = Object.values(PERM) as string[]
21
22const PERMISSION_KEYS: Record<
23 string,
24 | 'space.permission.viewSpace'
25 | 'space.permission.read'
26 | 'space.permission.message'
27 | 'space.permission.manageMembers'
28 | 'space.permission.manageRoles'
29 | 'space.permission.manageInvites'
30 | 'space.permission.manageBans'
31 | 'space.permission.manageChannels'
32 | 'space.permission.manageOverwrites'
33> = {
34 view_space: 'space.permission.viewSpace',
35 read: 'space.permission.read',
36 message: 'space.permission.message',
37 manage_members: 'space.permission.manageMembers',
38 manage_roles: 'space.permission.manageRoles',
39 manage_invites: 'space.permission.manageInvites',
40 manage_bans: 'space.permission.manageBans',
41 manage_channels: 'space.permission.manageChannels',
42 manage_overwrites: 'space.permission.manageOverwrites',
43}
44
45/** Resolved lazily so permission chips follow the active locale. */
46export const PERMISSION_LABELS = new Proxy({} as Record<string, string>, {
47 get: (_target, key: string) => (PERMISSION_KEYS[key] ? t(PERMISSION_KEYS[key]) : undefined),
48})
49
50/** Server-side order (`ALL_PERMISSIONS`); the editor renders exactly this list. */
51export const ALL_PERMISSION_KEYS = [
52 'view_space',
53 'read',
54 'message',
55 'manage_members',
56 'manage_roles',
57 'manage_invites',
58 'manage_bans',
59 'manage_channels',
60 'manage_overwrites',
61] as const
62
63export const permissionLabel = (key: string) => (PERMISSION_KEYS[key] ? t(PERMISSION_KEYS[key]) : key)
64
65export const JOIN_LEVEL_OPTIONS = [
66 {
67 value: 'None',
68 get label() {
69 return t('space.joinLevel.none')
70 },
71 },
72 {
73 value: 'NeedMailActive',
74 get label() {
75 return t('space.joinLevel.mail')
76 },
77 },
78 {
79 value: 'NeedMailActiveAndLastOneMid',
80 get label() {
81 return t('space.joinLevel.mailDay')
82 },
83 },
84 {
85 value: 'NeedMailActiveAndLastOneHours',
86 get label() {
87 return t('space.joinLevel.mailHour')
88 },
89 },
90 {
91 value: 'NeedMailActiveAndPhoneActive',
92 get label() {
93 return t('space.joinLevel.mailPhone')
94 },
95 },
96]
97
98export const NOTIFICATION_OPTIONS = [
99 {
100 value: 'All',
101 get label() {
102 return t('space.notify.all')
103 },
104 },
105 {
106 value: 'Mentions',
107 get label() {
108 return t('space.notify.mentions')
109 },
110 },
111 {
112 value: 'Nothing',
113 get label() {
114 return t('space.notify.nothing')
115 },
116 },
117]
118
119const MEMBER_STATUS_KEYS: Record<string, MessageKey> = {
120 Active: 'space.memberStatus.active',
121 Inactive: 'space.memberStatus.inactive',
122 Left: 'space.memberStatus.left',
123 Removed: 'space.memberStatus.removed',
124}
125
126export const MEMBER_STATUS_LABELS = new Proxy({} as Record<string, string>, {
127 get: (_target, key: string) => (MEMBER_STATUS_KEYS[key] ? t(MEMBER_STATUS_KEYS[key]) : undefined),
128})
129
130export const JOIN_TRIGGER_OPTIONS = [
131 {
132 value: 'issue_reaction',
133 get label() {
134 return t('space.joinTrigger.issueReaction')
135 },
136 },
137 {
138 value: 'issue_comment_reaction',
139 get label() {
140 return t('space.joinTrigger.commentReaction')
141 },
142 },
143]
144
145export const IMAGE_KINDS = [
146 {
147 kind: 'avatar',
148 get label() {
149 return t('space.image.avatar')
150 },
151 get hint() {
152 return t('space.image.avatarHint')
153 },
154 },
155 {
156 kind: 'banner',
157 get label() {
158 return t('space.image.banner')
159 },
160 get hint() {
161 return t('space.image.bannerHint')
162 },
163 },
164 {
165 kind: 'invite_banner',
166 get label() {
167 return t('space.image.inviteBanner')
168 },
169 get hint() {
170 return t('space.image.inviteBannerHint')
171 },
172 },
173 {
174 kind: 'embed_splash',
175 get label() {
176 return t('space.image.embedSplash')
177 },
178 get hint() {
179 return t('space.image.embedSplashHint')
180 },
181 },
182] as const
183
184/** Bit 0 of a directory overwrite: the node is visible to that subject. */
185export const PERM_DIR_VISIBLE = 1
186
187/** Indented options for a parent picker: only real folders can hold things. */
188export function parentDirOptions(nodes: DirNode[], prefix = ''): { value: string; label: string }[] {
189 const out: { value: string; label: string }[] = []
190 for (const node of nodes) {
191 if (node.kind === 'Dir') out.push({ value: node.id, label: `${prefix}${node.name}` })
192 out.push(...parentDirOptions(node.children ?? [], `${prefix}${node.name} / `))
193 }
194 return out
195}
196
197/** Every node in the tree, folders and object nodes alike: overwrites attach to both. */
198export function allDirNodes(nodes: DirNode[], depth = 0): { node: DirNode; depth: number }[] {
199 return nodes.flatMap((node) => [{ node, depth }, ...allDirNodes(node.children ?? [], depth + 1)])
200}
201
202/** Repo/board/channel slugs follow the space-name grammar. */
203export const slug = (value: string) => value.toLowerCase().replace(/[^a-z0-9-]/g, '')
204
205export function userLabel(user?: PublicUser | null) {
206 if (!user) return t('common.state.unknownUser')
207 const name = user.display_name || user.username
208 return user.username ? t('space.userLabel', { name, username: user.username }) : name
209}
210
211/** `#4f7d3a` / `4f7d3a` → `#4f7d3a`; anything else falls back to the neutral default. */
212export function normalizeHexColor(value: string) {
213 const hex = value.trim().replace(/^#?/, '#')
214 return /^#[0-9a-fA-F]{6}$/.test(hex) ? hex.toLowerCase() : '#6b7280'
215}
Added view/app/src/domains/space/management/ui/space-settings/my-membership-section.tsx view/app/src/domains/space/management/ui/space-settings/my-membership-section.tsx +128 −0 View view/app/src/domains/space/management/ui/space-settings/my-membership-section.tsx
@@ -0,0 +1,128 @@
1import { Busy, Field, fmtDate, inputCls, SaveBar, SectionTitle, Toggle, useSave } from '@app/domains/shared'
2import { useT } from '@app/i18n'
3import { spaceAdminKey, useAsync } from '@app/lib'
4import { useState } from 'react'
5import { getSpaceMember, type SpaceMember, updateMySpaceMembership } from '../../model/space-admin'
6import { NOTIFICATION_OPTIONS } from './meta'
7import type { SpaceSectionProps } from './props'
8
9type Draft = {
10 alias_name: string | null
11 message_notifications: SpaceMember['message_notifications']
12 mobile_push: boolean
13 suppress_everyone: boolean
14 suppress_roles: boolean
15 hide_muted_channels: boolean
16}
17
18/**
19 * The viewer's own row in this community. If the read fails the form starts
20 * blank, and only what is typed gets sent.
21 */
22export function MyMembershipSection({ spaceName, viewerId }: SpaceSectionProps) {
23 const { t } = useT()
24 // `GET /members/{id}` lets a caller read their own row without any
25 // management permission, unlike the member list.
26 const mine = useAsync(
27 () => (viewerId ? getSpaceMember(spaceName, viewerId).catch(() => null) : Promise.resolve(null)),
28 [spaceName, viewerId],
29 { cacheKey: viewerId ? spaceAdminKey(spaceName, 'membership') : null },
30 )
31
32 if (mine.loading)
33 return (
34 <>
35 <SectionTitle title={t('space.settings.sections.mine')} />
36 <Busy />
37 </>
38 )
39
40 // Rendered only after the read settles, so the draft is seeded in one pass.
41 return <SelfForm spaceName={spaceName} row={mine.data ?? null} />
42}
43
44function SelfForm({ spaceName, row }: { spaceName: string; row: SpaceMember | null }) {
45 const { t } = useT()
46 const { busy, notice, error, run } = useSave()
47 const [draft, setDraft] = useState<Draft>({
48 alias_name: row?.alias_name ?? null,
49 message_notifications: row?.message_notifications ?? 'all',
50 mobile_push: row?.mobile_push ?? true,
51 suppress_everyone: row?.suppress_everyone ?? false,
52 suppress_roles: row?.suppress_roles ?? false,
53 hide_muted_channels: row?.hide_muted_channels ?? false,
54 })
55 const set = <K extends keyof Draft>(key: K, value: Draft[K]) => setDraft((prev) => ({ ...prev, [key]: value }))
56
57 return (
58 <section>
59 <SectionTitle
60 title={t('space.settings.sections.mine')}
61 sub={row ? t('space.membership.subJoined', { date: fmtDate(row.joined_at) }) : t('space.membership.sub')}
62 />
63 <Field label={t('space.alias.label')} hint={t('space.alias.hint')}>
64 <input
65 className={inputCls}
66 maxLength={64}
67 placeholder={t('space.images.unset')}
68 value={draft.alias_name ?? ''}
69 onChange={(e) => set('alias_name', e.target.value || null)}
70 />
71 </Field>
72 <Field label={t('space.alerts.label')}>
73 <select
74 className={inputCls}
75 value={draft.message_notifications}
76 onChange={(e) => set('message_notifications', e.target.value as Draft['message_notifications'])}
77 >
78 {NOTIFICATION_OPTIONS.map((option) => (
79 <option key={option.value} value={option.value}>
80 {option.label}
81 </option>
82 ))}
83 </select>
84 </Field>
85 <Toggle
86 checked={draft.mobile_push}
87 onChange={(v) => set('mobile_push', v)}
88 label={t('space.alerts.mobilePush')}
89 />
90 <Toggle
91 checked={draft.suppress_everyone}
92 onChange={(v) => set('suppress_everyone', v)}
93 label={t('space.alerts.suppressEveryone')}
94 />
95 <Toggle
96 checked={draft.suppress_roles}
97 onChange={(v) => set('suppress_roles', v)}
98 label={t('space.alerts.suppressRoles')}
99 />
100 <Toggle
101 checked={draft.hide_muted_channels}
102 onChange={(v) => set('hide_muted_channels', v)}
103 label={t('space.alerts.hideMuted')}
104 />
105 <div className="mt-7">
106 <SaveBar
107 busy={busy}
108 notice={notice}
109 error={error}
110 label={t('space.membership.save')}
111 onSave={() =>
112 void run(async () => {
113 const alias = draft.alias_name === null ? null : draft.alias_name.trim() || null
114 await updateMySpaceMembership(spaceName, {
115 alias_name: alias,
116 message_notifications: draft.message_notifications,
117 mobile_push: draft.mobile_push,
118 suppress_everyone: draft.suppress_everyone,
119 suppress_roles: draft.suppress_roles,
120 hide_muted_channels: draft.hide_muted_channels,
121 })
122 }, t('space.membership.saved'))
123 }
124 />
125 </div>
126 </section>
127 )
128}
Added view/app/src/domains/space/management/ui/space-settings/props.ts view/app/src/domains/space/management/ui/space-settings/props.ts +14 −0 View view/app/src/domains/space/management/ui/space-settings/props.ts
@@ -0,0 +1,14 @@
1import type { SpaceDto } from '../../model/api'
2
3/** Everything a settings section needs about the current space and viewer. */
4export interface SpaceSectionProps {
5 spaceName: string
6 /** Authoritative `GET /spaces/{name}` row, loaded once by the modal. */
7 space: SpaceDto
8 perms: Set<string>
9 viewerId?: number
10 /** `PATCH/DELETE /spaces/{name}` are owner-only on the server. */
11 isOwner: boolean
12 /** The space row changed (name, icon, …) — refetch the rail and header. */
13 onSpaceUpdated: () => void
14}
Added view/app/src/domains/space/management/ui/space-settings/roles-section.tsx view/app/src/domains/space/management/ui/space-settings/roles-section.tsx +437 −0 View view/app/src/domains/space/management/ui/space-settings/roles-section.tsx
@@ -0,0 +1,437 @@
1import { Alert } from '@app/components'
2import { EmptyNote, Field, fmtDate, inputCls, SaveBar, SectionTitle, useSave } from '@app/domains/shared'
3import { useT } from '@app/i18n'
4import { spaceAdminMembersKey, useAsync } from '@app/lib'
5import { Button, Checkbox } from '@app/ui'
6import { ArrowLeft, Plus, Search } from 'lucide-react'
7import { useState } from 'react'
8import {
9 assignSpaceGroup,
10 createSpaceGroup,
11 deleteSpaceGroup,
12 joinSpaceGroup,
13 leaveSpaceGroup,
14 listSpaceMembers,
15 type SpaceGroup,
16 unassignSpaceGroup,
17 updateSpaceGroup,
18} from '../../model/space-admin'
19import { ALL_PERMISSION_KEYS, normalizeHexColor, permissionLabel, userLabel } from './meta'
20
21/**
22 * Roles' own sub-navigation: it replaces the settings section list while the
23 * roles pane is open — title, back, create, then one row per role.
24 */
25export function RolesNav({
26 groups,
27 loading,
28 selectedId,
29 creating,
30 onSelect,
31 onCreate,
32 onBack,
33}: {
34 groups: SpaceGroup[]
35 loading: boolean
36 selectedId: string | null
37 creating: boolean
38 onSelect: (id: string) => void
39 onCreate: () => void
40 onBack: () => void
41}) {
42 const { t } = useT()
43 return (
44 <div className="flex min-h-0 flex-1 flex-col">
45 <p className="settings-nav-title">{t('space.roles.title')}</p>
46 <button type="button" onClick={onBack} className="settings-nav-item">
47 <ArrowLeft className="h-4 w-4 shrink-0" aria-hidden />
48 <span className="truncate">{t('space.roles.back')}</span>
49 </button>
50 <button
51 type="button"
52 onClick={onCreate}
53 className={`settings-nav-item ${creating ? 'bg-shell-active text-shell-ink' : ''}`}
54 >
55 <Plus className="h-4 w-4 shrink-0" aria-hidden />
56 <span className="truncate">{t('space.roles.create')}</span>
57 </button>
58 <div className="mt-1 min-h-0 flex-1 space-y-px overflow-y-auto">
59 {loading ? <p className="px-2 py-3 text-[13px] text-shell-ink-muted">{t('space.roles.loading')}</p> : null}
60 {!loading && groups.length === 0 ? (
61 <p className="px-2 py-3 text-[13px] text-shell-ink-muted">{t('space.roles.empty')}</p>
62 ) : null}
63 {groups.map((group) => (
64 <button
65 key={group.id}
66 type="button"
67 aria-current={!creating && selectedId === group.id ? 'page' : undefined}
68 onClick={() => onSelect(group.id)}
69 className="settings-nav-item"
70 >
71 <span
72 aria-hidden
73 className="grid h-4 w-4 shrink-0 place-items-center rounded-full text-[10px]"
74 style={{ background: `${normalizeHexColor(group.color)}33` }}
75 >
76 {group.unicode_emoji || (
77 <span className="h-2 w-2 rounded-full" style={{ background: normalizeHexColor(group.color) }} />
78 )}
79 </span>
80 <span className="truncate">{group.name}</span>
81 </button>
82 ))}
83 </div>
84 </div>
85 )
86}
87
88/** Roles pane: create form or the selected role's editor. */
89export function RolesSection({
90 spaceName,
91 groups,
92 loading,
93 roleId,
94 creating,
95 onRefresh,
96 onCreated,
97}: {
98 spaceName: string
99 groups: SpaceGroup[]
100 loading: boolean
101 roleId: string | null
102 creating: boolean
103 onRefresh: () => void
104 onCreated: (id?: string) => void
105}) {
106 const { t } = useT()
107 const [tick] = useState(0)
108 const members = useAsync(() => listSpaceMembers(spaceName), [spaceName, tick], {
109 cacheKey: spaceAdminMembersKey(spaceName),
110 })
111 const selected = groups.find((group) => group.id === roleId) ?? groups[0] ?? null
112 const memberOptions = (members.data ?? [])
113 .filter((member) => member.user)
114 .map((member) => ({ value: member.user!.user_id, label: userLabel(member.user!) }))
115
116 return (
117 <section>
118 <SectionTitle title={t('space.roles.title')} sub={t('space.roles.sub')} />
119 {creating ? (
120 <CreateRole spaceName={spaceName} onDone={onCreated} />
121 ) : loading && !selected ? (
122 <EmptyNote>{t('space.roles.loading')}</EmptyNote>
123 ) : selected ? (
124 <RoleEditor
125 key={selected.id}
126 spaceName={spaceName}
127 group={selected}
128 memberOptions={memberOptions}
129 onSaved={onRefresh}
130 />
131 ) : (
132 <EmptyNote>{t('space.roles.emptyHint')}</EmptyNote>
133 )}
134 </section>
135 )
136}
137
138function CreateRole({ spaceName, onDone }: { spaceName: string; onDone: (id?: string) => void }) {
139 const { t } = useT()
140 const [draft, setDraft] = useState({ name: '', color: '#6b7280', unicode_emoji: '' })
141 const { busy, notice, error, run } = useSave()
142
143 return (
144 <div className="rounded-xl bg-shell-field p-4">
145 <h3 className="text-[15px] font-semibold">{t('space.roles.create')}</h3>
146 <div className="mt-3 grid gap-3 sm:grid-cols-[minmax(0,1fr)_auto_auto]">
147 <Field label={t('space.roles.name')}>
148 <input
149 className={inputCls}
150 placeholder={t('space.roles.namePlaceholder')}
151 maxLength={64}
152 value={draft.name}
153 onChange={(e) => setDraft({ ...draft, name: e.target.value })}
154 aria-label={t('space.roles.nameAria')}
155 />
156 </Field>
157 <Field label={t('space.roles.color')}>
158 <input
159 type="color"
160 className="h-10 w-14 cursor-pointer rounded-md border border-shell-hairline bg-shell-field px-1"
161 value={normalizeHexColor(draft.color)}
162 onChange={(e) => setDraft({ ...draft, color: e.target.value })}
163 aria-label={t('space.roles.colorAria')}
164 />
165 </Field>
166 <Field label={t('space.roles.emoji')}>
167 <input
168 className={`${inputCls} w-16 text-center`}
169 placeholder="🙂"
170 maxLength={8}
171 value={draft.unicode_emoji}
172 onChange={(e) => setDraft({ ...draft, unicode_emoji: e.target.value })}
173 aria-label={t('space.roles.emojiAria')}
174 />
175 </Field>
176 </div>
177 <div className="mt-3 flex items-center gap-2">
178 <Button
179 variant="primary"
180 className="h-9 text-[13px]"
181 disabled={busy || !draft.name.trim()}
182 onClick={() =>
183 void run(async () => {
184 const created = await createSpaceGroup(spaceName, {
185 name: draft.name.trim(),
186 color: normalizeHexColor(draft.color),
187 unicode_emoji: draft.unicode_emoji.trim() || null,
188 permissions: [],
189 })
190 onDone(created?.id)
191 }, t('space.roles.created'))
192 }
193 >
194 {t('space.roles.create')}
195 </Button>
196 <Button variant="ghost" className="h-9 text-[13px]" onClick={() => onDone()}>
197 {t('space.roles.cancel')}
198 </Button>
199 </div>
200 {error ? (
201 <div className="mt-3">
202 <Alert tone="error">{error}</Alert>
203 </div>
204 ) : null}
205 {notice && !error ? (
206 <div className="mt-3">
207 <Alert tone="success">{notice}</Alert>
208 </div>
209 ) : null}
210 </div>
211 )
212}
213
214function RoleEditor({
215 spaceName,
216 group,
217 memberOptions,
218 onSaved,
219}: {
220 spaceName: string
221 group: SpaceGroup
222 memberOptions: { value: number; label: string }[]
223 onSaved: () => void
224}) {
225 const [name, setName] = useState(group.name)
226 const [color, setColor] = useState(normalizeHexColor(group.color))
227 const [emoji, setEmoji] = useState(group.unicode_emoji ?? '')
228 const [position, setPosition] = useState(String(group.position))
229 const [perms, setPerms] = useState<Set<string>>(() => new Set(group.permissions))
230 const [query, setQuery] = useState('')
231 const [target, setTarget] = useState(0)
232 const { t } = useT()
233 const { run, busy, notice, error } = useSave()
234 const dirty =
235 name !== group.name ||
236 color !== normalizeHexColor(group.color) ||
237 emoji !== (group.unicode_emoji ?? '') ||
238 position !== String(group.position) ||
239 perms.size !== group.permissions.length ||
240 group.permissions.some((key) => !perms.has(key))
241
242 const togglePerm = (key: string) =>
243 setPerms((prev) => {
244 const next = new Set(prev)
245 if (next.has(key)) next.delete(key)
246 else next.add(key)
247 return next
248 })
249
250 const needle = query.trim().toLowerCase()
251 const visiblePerms = ALL_PERMISSION_KEYS.filter((key) =>
252 needle ? permissionLabel(key).toLowerCase().includes(needle) || key.includes(needle) : true,
253 )
254
255 return (
256 <div className="min-w-0">
257 <h3 className="text-[15px] font-semibold">{t('space.roles.editTitle', { name: group.name })}</h3>
258 <p className="mt-0.5 text-[12.5px] text-shell-ink-muted">{t('space.roles.editorSub')}</p>
259
260 <p className="mt-5 font-mono text-[10px] font-bold uppercase tracking-[1.2px] text-shell-ink-muted">
261 {t('space.roles.settingsGroup')}
262 </p>
263 <div className="mt-2 grid gap-x-4 sm:grid-cols-2">
264 <Field label={t('space.roles.name')}>
265 <input className={inputCls} maxLength={64} value={name} onChange={(e) => setName(e.target.value)} />
266 </Field>
267 <Field label={t('space.roles.color')} hint={t('space.roles.colorHint')}>
268 <div className="flex items-center gap-2">
269 <input
270 type="color"
271 className="h-10 w-14 shrink-0 cursor-pointer rounded-md border border-shell-hairline bg-shell-field px-1"
272 value={color}
273 onChange={(e) => setColor(e.target.value)}
274 aria-label={t('space.roles.colorAria2')}
275 />
276 <input
277 className={`${inputCls} font-mono text-[12.5px]`}
278 value={color}
279 onChange={(e) => setColor(e.target.value)}
280 maxLength={32}
281 />
282 </div>
283 </Field>
284 <Field label={t('space.roles.emoji')} hint={t('space.roles.emojiHint')}>
285 <input className={inputCls} maxLength={8} value={emoji} onChange={(e) => setEmoji(e.target.value)} />
286 </Field>
287 <Field label={t('space.roles.position')} hint={t('space.roles.positionHint')}>
288 <input type="number" className={inputCls} value={position} onChange={(e) => setPosition(e.target.value)} />
289 </Field>
290 </div>
291
292 <div className="mt-5 flex flex-wrap items-center gap-2">
293 <p className="mr-auto font-mono text-[10px] font-bold uppercase tracking-[1.2px] text-shell-ink-muted">
294 {t('space.roles.permissions')}
295 </p>
296 <Button
297 variant="secondary"
298 className="h-8 text-[12.5px]"
299 disabled={busy || perms.size === 0}
300 onClick={() => setPerms(new Set())}
301 >
302 {t('space.roles.clearPermissions')}
303 </Button>
304 </div>
305 <label className="mt-2 flex h-9 items-center gap-2 rounded-lg border border-shell-hairline bg-shell-field px-2.5">
306 <Search className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
307 <input
308 value={query}
309 onChange={(e) => setQuery(e.target.value)}
310 placeholder={t('space.roles.permissionSearch')}
311 aria-label={t('space.roles.permissionSearch')}
312 className="w-full min-w-0 bg-transparent text-[13px] outline-none"
313 />
314 </label>
315 <p className="mt-4 px-1 text-[11px] font-bold uppercase tracking-[0.06em] text-shell-ink-muted">
316 {t('space.roles.scopeCommunity')}
317 </p>
318 <div className="mt-1">
319 {visiblePerms.length === 0 ? (
320 <p className="px-1 py-3 text-[13px] text-shell-ink-muted">{t('space.roles.noPermissionMatch')}</p>
321 ) : null}
322 {visiblePerms.map((key) => (
323 <label
324 key={key}
325 className="flex cursor-pointer items-center justify-between gap-3 rounded-md px-1 py-1.5 text-[13.5px] transition-colors hover:bg-shell-field"
326 >
327 <span className="min-w-0 truncate">{permissionLabel(key)}</span>
328 <Checkbox checked={perms.has(key)} onChange={() => togglePerm(key)} aria-label={permissionLabel(key)} />
329 </label>
330 ))}
331 </div>
332
333 <div className="mt-4">
334 <SaveBar
335 busy={busy}
336 notice={notice}
337 error={error}
338 label={t('space.roles.save')}
339 onSave={() =>
340 void run(async () => {
341 await updateSpaceGroup(spaceName, group.id, {
342 name: name.trim() || group.name,
343 color: normalizeHexColor(color),
344 unicode_emoji: emoji.trim() || null,
345 position: Number.isFinite(Number(position)) ? Number(position) : group.position,
346 permissions: [...perms],
347 })
348 onSaved()
349 }, t('space.roles.saved'))
350 }
351 extra={dirty ? <span className="text-[12px] text-shell-ink-muted">{t('space.roles.dirty')}</span> : undefined}
352 />
353 </div>
354
355 <div className="mt-5 flex flex-wrap items-center gap-2 border-t border-shell-hairline pt-4">
356 <select
357 className={`${inputCls} h-9 w-auto max-w-[220px]`}
358 value={target}
359 onChange={(e) => setTarget(Number(e.target.value))}
360 aria-label={t('space.roles.selectMember')}
361 >
362 <option value="">{t('space.roles.selectMemberPlaceholder')}</option>
363 {memberOptions.map((option) => (
364 <option key={option.value} value={option.value}>
365 {option.label}
366 </option>
367 ))}
368 </select>
369 <Button
370 variant="secondary"
371 className="h-8 text-[12.5px]"
372 disabled={busy || !target}
373 onClick={() =>
374 void run(async () => {
375 await assignSpaceGroup(spaceName, target, group.id)
376 setTarget(0)
377 onSaved()
378 }, t('space.roles.granted'))
379 }
380 >
381 {t('space.roles.grant')}
382 </Button>
383 <Button
384 variant="ghost"
385 className="h-8 text-[12.5px]"
386 disabled={busy || !target}
387 onClick={() =>
388 void run(async () => {
389 await unassignSpaceGroup(spaceName, target, group.id)
390 setTarget(0)
391 onSaved()
392 }, t('space.roles.revoked'))
393 }
394 >
395 {t('space.roles.revoke')}
396 </Button>
397 <span className="ml-auto flex items-center gap-2">
398 <Button
399 variant="ghost"
400 className="h-8 text-[12.5px]"
401 disabled={busy}
402 onClick={() => void run(() => joinSpaceGroup(spaceName, group.id), t('space.roles.joined'))}
403 >
404 {t('space.roles.join')}
405 </Button>
406 <Button
407 variant="ghost"
408 className="h-8 text-[12.5px]"
409 disabled={busy}
410 onClick={() => void run(() => leaveSpaceGroup(spaceName, group.id), t('space.roles.left'))}
411 >
412 {t('space.roles.leave')}
413 </Button>
414 </span>
415 </div>
416 <p className="mt-3 text-[11.5px] text-shell-ink-muted">{t('space.roles.noReadback')}</p>
417 <p className="mt-1 text-[11.5px] text-shell-ink-muted">
418 {t('space.roles.createdAt', { date: fmtDate(group.created_at) })}
419 </p>
420 <div className="mt-4 border-t border-shell-hairline pt-4">
421 <Button
422 variant="secondary"
423 className="h-8 text-[12.5px]"
424 disabled={busy}
425 onClick={() =>
426 void run(async () => {
427 await deleteSpaceGroup(spaceName, group.id)
428 onSaved()
429 }, t('space.roles.deleted'))
430 }
431 >
432 {t('space.roles.delete')}
433 </Button>
434 </div>
435 </div>
436 )
437}
Added view/app/src/domains/space/management/ui/create-space-modal.tsx view/app/src/domains/space/management/ui/create-space-modal.tsx +137 −0 View view/app/src/domains/space/management/ui/create-space-modal.tsx
@@ -0,0 +1,137 @@
1import { Alert } from '@app/components'
2import { useT } from '@app/i18n'
3import { ApiFailure } from '@app/lib'
4import { Button, Checkbox } from '@app/ui'
5import { X } from 'lucide-react'
6import { type FormEvent, useEffect, useState } from 'react'
7import { createSpace } from '../model/api'
8
9/** Backend rule: lowercase ascii letters, digits and hyphens, max 64 chars. */
10const NAME_RE = /^[a-z0-9-]{1,64}$/
11
12export interface CreateSpaceModalProps {
13 open: boolean
14 onClose: () => void
15 /** Called with the created space's name so the shell can reload and navigate. */
16 onCreated: (spaceName: string) => void
17}
18
19/** Modal dialog around `POST /spaces`: slug name, optional description/privacy. */
20export function CreateSpaceModal({ open, onClose, onCreated }: CreateSpaceModalProps) {
21 const { t } = useT()
22 const [name, setName] = useState('')
23 const [description, setDescription] = useState('')
24 const [isPrivate, setIsPrivate] = useState(false)
25 const [busy, setBusy] = useState(false)
26 const [error, setError] = useState<string | null>(null)
27
28 useEffect(() => {
29 if (!open) return
30 const onKey = (event: KeyboardEvent) => {
31 if (event.key === 'Escape') onClose()
32 }
33 window.addEventListener('keydown', onKey)
34 return () => window.removeEventListener('keydown', onKey)
35 }, [open, onClose])
36
37 if (!open) return null
38
39 async function onSubmit(event: FormEvent) {
40 event.preventDefault()
41 if (busy) return
42 if (!NAME_RE.test(name)) {
43 setError(t('space.create.nameInvalid'))
44 return
45 }
46 setBusy(true)
47 setError(null)
48 try {
49 await createSpace({
50 name,
51 slug: name
52 .trim()
53 .toLowerCase()
54 .replace(/[^a-z0-9-]+/g, '-')
55 .replace(/^-|-$/g, ''),
56 description: description.trim() || null,
57 allow_join: !isPrivate,
58 })
59 onCreated(name)
60 setName('')
61 setDescription('')
62 setIsPrivate(false)
63 } catch (err) {
64 setError(err instanceof ApiFailure ? err.message : t('space.create.failed'))
65 } finally {
66 setBusy(false)
67 }
68 }
69
70 return (
71 <div className="workspace-overlay fixed inset-0 z-50 flex items-center justify-center p-4" onClick={onClose}>
72 <div
73 role="dialog"
74 aria-modal
75 aria-label={t('space.create')}
76 className="workspace-dialog w-full max-w-[420px] bg-shell-surface"
77 onClick={(e) => e.stopPropagation()}
78 >
79 <div className="mb-4 flex items-center justify-between">
80 <h2 className="text-[16px] font-semibold">{t('space.create')}</h2>
81 <button
82 type="button"
83 aria-label={t('common.action.close')}
84 className="text-shell-ink-muted transition-colors hover:text-shell-ink"
85 onClick={onClose}
86 >
87 <X className="h-4 w-4" />
88 </button>
89 </div>
90 <form className="flex flex-col gap-4" onSubmit={onSubmit} noValidate>
91 <label className="flex flex-col gap-1.5">
92 <span className="font-mono text-[10px] font-bold uppercase tracking-[1.2px] text-shell-ink-muted">
93 {t('space.create.nameRequired')}
94 </span>
95 <input
96 autoFocus
97 value={name}
98 onChange={(e) => setName(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
99 maxLength={64}
100 placeholder="kernel-dev"
101 className="h-10 w-full rounded-md border-2 border-shell-hairline bg-shell-field px-3 text-[13.5px] text-shell-ink outline-none transition-colors focus:border-shell-ink"
102 />
103 <span className="text-[11px] text-shell-ink-muted">{t('space.create.nameHint')}</span>
104 </label>
105 <label className="flex flex-col gap-1.5">
106 <span className="font-mono text-[10px] font-bold uppercase tracking-[1.2px] text-shell-ink-muted">
107 {t('space.create.description')}
108 </span>
109 <textarea
110 value={description}
111 onChange={(e) => setDescription(e.target.value)}
112 maxLength={512}
113 rows={3}
114 placeholder={t('space.create.descriptionPlaceholder')}
115 className="w-full resize-none rounded-md border-2 border-shell-hairline bg-shell-field px-3 py-2 text-[13.5px] text-shell-ink outline-none transition-colors focus:border-shell-ink"
116 />
117 </label>
118 <div className="flex items-center gap-2 text-[13px]">
119 <Checkbox checked={isPrivate} onChange={setIsPrivate} aria-label={t('space.create.private')} />
120 <span className="cursor-pointer" onClick={() => setIsPrivate((p) => !p)}>
121 {t('space.create.private')}
122 </span>
123 </div>
124 {error && <Alert tone="error">{error}</Alert>}
125 <div className="flex justify-end gap-2 pt-1">
126 <Button variant="secondary" onClick={onClose} disabled={busy}>
127 {t('common.action.cancel')}
128 </Button>
129 <Button type="submit" disabled={busy}>
130 {busy ? t('space.create.creating') : t('common.action.create')}
131 </Button>
132 </div>
133 </form>
134 </div>
135 </div>
136 )
137}
Added view/app/src/domains/space/management/ui/privacy-dialog.tsx view/app/src/domains/space/management/ui/privacy-dialog.tsx +181 −0 View view/app/src/domains/space/management/ui/privacy-dialog.tsx
@@ -0,0 +1,181 @@
1import { errText } from '@app/domains/shared'
2import { useT } from '@app/i18n'
3import { Check, X } from 'lucide-react'
4import { useEffect, useId, useRef, useState } from 'react'
5import { fetchPreferences, type UserPreferences, updatePreferences } from '../model/api'
6
7type Key = 'allow_dms_from' | 'allow_mentions_from' | 'show_online_status'
8type Patch = Partial<Pick<UserPreferences, Key>>
9
10export function PrivacyDialog({ onClose }: { onClose: () => void }) {
11 const { t } = useT()
12 const titleId = useId()
13 const [prefs, setPrefs] = useState<UserPreferences | null>(null)
14 const [error, setError] = useState<string | null>(null)
15 const [pending, setPending] = useState(0)
16 const [saved, setSaved] = useState(false)
17 const queue = useRef<Promise<unknown>>(Promise.resolve())
18 const savedTimer = useRef(0)
19 const closeRef = useRef(onClose)
20
21 useEffect(() => {
22 closeRef.current = onClose
23 }, [onClose])
24
25 useEffect(() => {
26 let active = true
27 const previousOverflow = document.body.style.overflow
28 document.body.style.overflow = 'hidden'
29 const onKey = (event: KeyboardEvent) => {
30 if (event.key !== 'Escape') return
31 event.preventDefault()
32 closeRef.current()
33 }
34 window.addEventListener('keydown', onKey)
35 fetchPreferences().then(
36 (value) => {
37 if (active) setPrefs(value)
38 },
39 (err) => {
40 if (active) setError(errText(err))
41 },
42 )
43 return () => {
44 active = false
45 document.body.style.overflow = previousOverflow
46 window.removeEventListener('keydown', onKey)
47 window.clearTimeout(savedTimer.current)
48 }
49 }, [])
50
51 const flip = (patch: Patch) => {
52 setPrefs((current) => (current ? { ...current, ...patch } : current))
53 setError(null)
54 setPending((count) => count + 1)
55 const run = () =>
56 updatePreferences(patch).then(
57 () => {
58 setPending((count) => count - 1)
59 setSaved(true)
60 window.clearTimeout(savedTimer.current)
61 savedTimer.current = window.setTimeout(() => setSaved(false), 1600)
62 },
63 (err) => {
64 setPending((count) => count - 1)
65 setError(errText(err))
66 },
67 )
68 queue.current = queue.current.then(run, run)
69 }
70
71 const audience = (value: 'Everyone' | 'FollowingOnly' | 'Nobody' | undefined) => (value ? value !== 'Nobody' : false)
72 const rows: Array<{ key: Key; label: string; hint: string; checked: boolean; patch: (on: boolean) => Patch }> = [
73 {
74 key: 'allow_dms_from',
75 label: t('space.privacy.dms'),
76 hint: t('space.privacy.dmsHint'),
77 checked: audience(prefs?.allow_dms_from),
78 patch: (on) => ({ allow_dms_from: on ? 'Everyone' : 'Nobody' }),
79 },
80 {
81 key: 'allow_mentions_from',
82 label: t('space.privacy.mentions'),
83 hint: t('space.privacy.mentionsHint'),
84 checked: audience(prefs?.allow_mentions_from),
85 patch: (on) => ({ allow_mentions_from: on ? 'Everyone' : 'Nobody' }),
86 },
87 {
88 key: 'show_online_status',
89 label: t('space.privacy.online'),
90 hint: t('space.privacy.onlineHint'),
91 checked: prefs?.show_online_status ?? false,
92 patch: (on) => ({ show_online_status: on }),
93 },
94 ]
95
96 return (
97 <div className="fixed inset-0 z-50 grid place-items-center bg-black/50 p-4" onClick={() => closeRef.current()}>
98 <div
99 role="dialog"
100 aria-modal
101 aria-labelledby={titleId}
102 onClick={(event) => event.stopPropagation()}
103 className="w-full max-w-[430px] rounded-2xl bg-shell-surface px-5 pb-3 pt-4 shadow-[0_18px_50px_rgb(0_0_0/28%)]"
104 >
105 <div className="flex items-start justify-between gap-3">
106 <h2 id={titleId} className="text-[17px] font-bold">
107 {t('space.privacy.title')}
108 </h2>
109 <button
110 type="button"
111 aria-label={t('ui.close')}
112 onClick={() => closeRef.current()}
113 className="-mr-1 grid h-8 w-8 shrink-0 place-items-center rounded-full text-shell-ink-muted transition-colors hover:bg-shell-active hover:text-shell-ink"
114 >
115 <X className="h-4 w-4" />
116 </button>
117 </div>
118 <div className="space-y-0.5 pb-1 pt-1">
119 {rows.map((row) => (
120 <div key={row.key} className="flex items-center justify-between gap-4 py-3">
121 <div className="min-w-0">
122 <p className="text-[14.5px] font-semibold">{row.label}</p>
123 <p className="mt-0.5 text-[12.5px] text-shell-ink-muted">{row.hint}</p>
124 </div>
125 <Switch
126 checked={row.checked}
127 disabled={!prefs}
128 label={row.label}
129 onChange={(on) => flip(row.patch(on))}
130 />
131 </div>
132 ))}
133 </div>
134 <p
135 aria-live="polite"
136 className={`h-4 truncate text-[11.5px] ${error ? 'text-[#f23f43]' : 'text-shell-ink-muted'}`}
137 >
138 {error ?? (pending > 0 ? t('ui.saving') : saved ? t('space.privacy.saved') : '')}
139 </p>
140 </div>
141 </div>
142 )
143}
144
145function Switch({
146 checked,
147 disabled,
148 label,
149 onChange,
150}: {
151 checked: boolean
152 disabled: boolean
153 label: string
154 onChange: (value: boolean) => void
155}) {
156 return (
157 <button
158 type="button"
159 role="switch"
160 aria-checked={checked}
161 aria-label={label}
162 disabled={disabled}
163 onClick={() => onChange(!checked)}
164 className={`relative h-[26px] w-[46px] shrink-0 rounded-full transition-colors duration-200 ${
165 checked ? 'bg-[#5865f2]' : 'bg-shell-tile'
166 } ${disabled ? 'opacity-50' : ''}`}
167 >
168 <span
169 aria-hidden
170 className={`absolute left-[3px] top-[3px] grid h-5 w-5 place-items-center rounded-full bg-white shadow-sm transition-transform duration-200 ${
171 checked ? 'translate-x-[20px]' : ''
172 }`}
173 >
174 <Check
175 strokeWidth={3.5}
176 className={`h-3 w-3 text-[#5865f2] transition-opacity duration-200 ${checked ? 'opacity-100' : 'opacity-0'}`}
177 />
178 </span>
179 </button>
180 )
181}
Added view/app/src/domains/space/management/ui/space-header.tsx view/app/src/domains/space/management/ui/space-header.tsx +529 −0 View view/app/src/domains/space/management/ui/space-header.tsx
@@ -0,0 +1,529 @@
1import { FormModal } from '@app/domains/shared'
2import { useT } from '@app/i18n'
3import { spaceImageKey, useAsync } from '@app/lib'
4import type { LucideIcon } from 'lucide-react'
5import {
6 ChevronDown,
7 FolderGit2,
8 FolderPlus,
9 Hash,
10 LogOut,
11 Settings,
12 ShieldCheck,
13 SquareKanban,
14 UserPlus,
15} from 'lucide-react'
16import { type CSSProperties, type ReactNode, useEffect, useRef, useState } from 'react'
17import { Link } from 'react-router-dom'
18import type { MySpace } from '../model/api'
19import {
20 createBoard,
21 createInvite,
22 createRepo,
23 createRoom,
24 createSpaceDir,
25 fetchSpaceDir,
26 leaveSpace,
27} from '../model/api'
28import { findUserByUsername, getSpaceImage, listSpaceGroups } from '../model/space-admin'
29import { PrivacyDialog } from './privacy-dialog'
30import { MANAGE_PERMS, parentDirOptions } from './space-settings/meta'
31
32type Dialog = 'invite' | 'repo' | 'board' | 'room' | 'dir' | 'privacy' | 'leave' | null
33
34/** Repo/board/room slugs follow the space-name grammar (lowercase, digits, hyphen). */
35const slug = (v: string) => v.toLowerCase().replace(/[^a-z0-9-]/g, '')
36
37export interface SpaceHeaderProps {
38 mySpace: MySpace
39 viewerId?: number
40 /** A room/repo/board was created — sidebar lists should refetch. */
41 onListsChanged: () => void
42 /** Opened 社区设置: the overlay lives in the shell, outside the sidebar. */
43 onOpenSettings: () => void
44 /** Left the space — parent reloads the space list and navigates away. */
45 onLeft: () => void
46}
47
48/** AppShell `sideHeader` slot: space name + member count + the community menu. */
49export function SpaceHeader({ mySpace, viewerId, onListsChanged, onOpenSettings, onLeft }: SpaceHeaderProps) {
50 const { t } = useT()
51 const { space, permissions } = mySpace
52 const [menuOpen, setMenuOpen] = useState(false)
53 const [dialog, setDialog] = useState<Dialog>(null)
54 const [dirOptions, setDirOptions] = useState<{ value: string; label: string }[]>([])
55 const [groupOptions, setGroupOptions] = useState<{ value: string; label: string }[]>([])
56 const [failedBannerUrl, setFailedBannerUrl] = useState<string | null>(null)
57 const rootRef = useRef<HTMLDivElement>(null)
58
59 const perms = new Set(permissions)
60 // Each section inside the overlay is gated per endpoint; the entry point only
61 // needs one management permission to be worth showing.
62 const canManage = MANAGE_PERMS.some((key) => perms.has(key))
63 // /me/spaces ships `owner_uid`; the full SpaceDto embeds `owner`.
64 const ownerUid = space.owner?.user_id ?? (space as unknown as { owner_uid?: string }).owner_uid
65 const isOwner = Boolean(ownerUid && viewerId && ownerUid === viewerId)
66 // 逐项对齐服务端鉴权:仓库/导入永远 owner-only;看板/智能体/文件夹/聊天室都在
67 // `manage_channels` 一档(owner 天然具备全部权限)。
68 const canManageChannels = isOwner || perms.has('manage_channels')
69 // The image endpoint already enforces visibility for private communities and
70 // provides a presigned URL, so the sidebar can use the same uploaded banner
71 // as the community settings and directory views.
72 // Cached: switching between a space's chat, board and repo rebuilds the
73 // sidebar (each section renders its own layout), and an uncached fetch made
74 // that rebuild visible — the gradient showed for a frame and the banner
75 // popped in when the request landed. The local-first tier paints the known
76 // banner synchronously and still revalidates in the background.
77 const banner = useAsync(() => getSpaceImage(space.name, 'banner'), [space.name], {
78 cacheKey: spaceImageKey(space.name, 'banner'),
79 })
80 const bannerUrl = banner.data?.url
81 const showBannerImage = Boolean(bannerUrl && bannerUrl !== failedBannerUrl)
82
83 useEffect(() => {
84 if (!menuOpen) return
85 const onDown = (event: PointerEvent) => {
86 if (!rootRef.current?.contains(event.target as Node)) setMenuOpen(false)
87 }
88 const onKey = (event: KeyboardEvent) => {
89 if (event.key === 'Escape') setMenuOpen(false)
90 }
91 window.addEventListener('pointerdown', onDown)
92 window.addEventListener('keydown', onKey)
93 return () => {
94 window.removeEventListener('pointerdown', onDown)
95 window.removeEventListener('keydown', onKey)
96 }
97 }, [menuOpen])
98
99 const openDialog = (next: Exclude<Dialog, null>) => {
100 setMenuOpen(false)
101 setDialog(next)
102 }
103
104 // Parent pickers (folder/repo/board/room) need fresh tree data on each open.
105 useEffect(() => {
106 if (dialog !== 'dir' && dialog !== 'repo' && dialog !== 'board' && dialog !== 'room') return
107 let active = true
108 fetchSpaceDir(space.name).then(
109 (nodes) => {
110 if (active) setDirOptions(parentDirOptions(nodes ?? []))
111 },
112 () => {
113 if (active) setDirOptions([])
114 },
115 )
116 return () => {
117 active = false
118 }
119 }, [dialog, space.name])
120 const closeDialog = () => setDialog(null)
121
122 // The invite form needs the space's roles: the server requires at least one.
123 useEffect(() => {
124 if (dialog !== 'invite') return
125 let active = true
126 listSpaceGroups(space.name).then(
127 (groups) => {
128 if (active)
129 setGroupOptions(
130 groups.map((group) => ({
131 value: group.id,
132 label: group.unicode_emoji ? `${group.unicode_emoji} ${group.name}` : group.name,
133 })),
134 )
135 },
136 () => {
137 if (active) setGroupOptions([])
138 },
139 )
140 return () => {
141 active = false
142 }
143 }, [dialog, space.name])
144
145 return (
146 <div
147 ref={rootRef}
148 className="space-community-header relative min-w-0 flex-1"
149 style={{ '--space-banner-hue': String(bannerHue(space.name)) } as CSSProperties}
150 >
151 <div className={`space-community-banner${showBannerImage ? ' has-image' : ''}`} aria-hidden="true">
152 {showBannerImage ? (
153 <img src={bannerUrl} alt="" onError={() => bannerUrl && setFailedBannerUrl(bannerUrl)} />
154 ) : null}
155 </div>
156 <div className="space-community-menu-trigger">
157 <span className="space-community-avatar" aria-hidden="true">
158 {space.avatar_url ? <img src={space.avatar_url} alt="" /> : ([...space.name][0]?.toUpperCase() ?? '?')}
159 </span>
160 <span className="min-w-0 flex-1">
161 <button
162 type="button"
163 aria-haspopup="menu"
164 aria-expanded={menuOpen}
165 onClick={() => setMenuOpen((open) => !open)}
166 className="block max-w-full text-left"
167 >
168 <span className="space-community-name">{space.name}</span>
169 </button>
170 <Link
171 to={`/app/${encodeURIComponent(space.name)}/members`}
172 title={t('space.members.open')}
173 aria-label={t('space.members.open')}
174 className="space-community-members"
175 >
176 {space.members_count}
177 </Link>
178 </span>
179 <button
180 type="button"
181 aria-label={space.name}
182 aria-haspopup="menu"
183 aria-expanded={menuOpen}
184 onClick={() => setMenuOpen((open) => !open)}
185 className="shrink-0"
186 >
187 <ChevronDown className={`h-4 w-4 transition-transform ${menuOpen ? 'rotate-180' : ''}`} aria-hidden />
188 </button>
189 </div>
190
191 {menuOpen ? (
192 <div
193 role="menu"
194 className="absolute left-3 right-3 top-full z-40 mt-2 rounded-xl bg-shell-surface p-1 shadow-xl"
195 >
196 {perms.has('manage_invites') ? (
197 <MenuItem icon={UserPlus} label={t('space.menu.invite')} onClick={() => openDialog('invite')} />
198 ) : null}
199 {isOwner ? (
200 <MenuItem icon={FolderGit2} label={t('space.menu.createRepo')} onClick={() => openDialog('repo')} />
201 ) : null}
202 {canManageChannels ? (
203 <MenuItem icon={SquareKanban} label={t('space.menu.createBoard')} onClick={() => openDialog('board')} />
204 ) : null}
205 {canManageChannels ? (
206 <MenuItem icon={Hash} label={t('space.menu.createRoom')} onClick={() => openDialog('room')} />
207 ) : null}
208 {canManageChannels ? (
209 <MenuItem icon={FolderPlus} label={t('space.menu.createDir')} onClick={() => openDialog('dir')} />
210 ) : null}
211 <MenuDivider />
212 {canManage ? <MenuItem icon={Settings} label={t('space.menu.settings')} onClick={onOpenSettings} /> : null}
213 <MenuItem icon={ShieldCheck} label={t('space.menu.privacy')} onClick={() => openDialog('privacy')} />
214 {!isOwner ? (
215 <>
216 <MenuDivider />
217 <MenuItem danger icon={LogOut} label={t('space.menu.leave')} onClick={() => openDialog('leave')} />
218 </>
219 ) : null}
220 </div>
221 ) : null}
222
223 <FormModal
224 title={t('space.invite.title')}
225 description={t('space.invite.sub', { space: space.name })}
226 open={dialog === 'invite'}
227 wide
228 confirmLabel={t('space.invite.submit')}
229 fields={[
230 {
231 kind: 'text',
232 key: 'email',
233 label: t('space.invite.email'),
234 type: 'email',
235 optional: true,
236 placeholder: 'ada@example.com',
237 hint: t('space.invite.emailHint'),
238 },
239 {
240 kind: 'text',
241 key: 'username',
242 label: t('space.invite.username'),
243 optional: true,
244 placeholder: 'ada',
245 hint: t('space.invite.usernameHint'),
246 },
247 {
248 kind: 'datetime',
249 key: 'expires_at',
250 label: t('space.invite.expires'),
251 optional: true,
252 hint: t('space.invite.expiresHint'),
253 },
254 {
255 kind: 'multiselect',
256 key: 'group_ids',
257 label: t('space.invite.groups'),
258 options: groupOptions,
259 emptyLabel: t('space.invite.groupsEmpty'),
260 hint: t('space.invite.groupsHint'),
261 },
262 ]}
263 onSubmit={async (values) => {
264 const email = String(values.email).trim()
265 const username = String(values.username).trim().replace(/^@/, '')
266 if (!email && !username) throw new Error(t('space.invite.missingTarget'))
267 const expires = String(values.expires_at).trim()
268 const target = email ? null : (await findUserByUsername(username)).user_id
269 await createInvite(space.name, {
270 target_user_id: target,
271 max_uses: 0,
272 ttl_secs: expires ? Math.max(60, Math.floor((new Date(expires).getTime() - Date.now()) / 1000)) : null,
273 })
274 return t('space.invite.sent', { target: email || username })
275 }}
276 onClose={closeDialog}
277 />
278 <FormModal
279 title={t('space.repo.title')}
280 open={dialog === 'repo'}
281 confirmLabel={t('common.action.create')}
282 fields={[
283 {
284 kind: 'text',
285 key: 'name',
286 label: t('space.repo.name'),
287 placeholder: 'kernel',
288 hint: t('space.repo.nameHint'),
289 sanitize: slug,
290 },
291 { kind: 'text', key: 'description', label: t('space.repo.description'), optional: true },
292 {
293 kind: 'select',
294 key: 'parent_dir_id',
295 label: t('space.repo.dir'),
296 initialValue: '',
297 options: [{ value: '', label: t('space.dir.root') }, ...dirOptions],
298 },
299 {
300 kind: 'text',
301 key: 'default_branch',
302 label: t('space.repo.defaultBranch'),
303 placeholder: 'main',
304 optional: true,
305 },
306 {
307 kind: 'text',
308 key: 'license_name',
309 label: t('space.repo.license'),
310 placeholder: 'MIT',
311 optional: true,
312 },
313 {
314 kind: 'text',
315 key: 'init_message',
316 label: t('space.repo.initMessage'),
317 placeholder: 'Initial commit',
318 optional: true,
319 },
320 { kind: 'toggle', key: 'enable_lfs', label: t('space.repo.enableLfs') },
321 { kind: 'toggle', key: 'is_private', label: t('space.repo.private') },
322 ]}
323 onSubmit={async (values) => {
324 await createRepo(space.name, {
325 name: String(values.name),
326 description: String(values.description).trim() || null,
327 parent_dir_id: String(values.parent_dir_id) || null,
328 default_branch: String(values.default_branch).trim() || null,
329 license_name: String(values.license_name).trim() || null,
330 init_message: String(values.init_message).trim() || null,
331 enable_lfs: Boolean(values.enable_lfs),
332 is_private: Boolean(values.is_private),
333 })
334 onListsChanged()
335 }}
336 onClose={closeDialog}
337 />
338 <FormModal
339 title={t('space.board.create.title')}
340 open={dialog === 'board'}
341 confirmLabel={t('common.action.create')}
342 fields={[
343 { kind: 'text', key: 'name', label: t('space.board.nameField'), placeholder: 'bug-triage' },
344 {
345 kind: 'textarea',
346 key: 'description',
347 label: t('space.board.description'),
348 optional: true,
349 maxLength: 512,
350 },
351 {
352 kind: 'text',
353 key: 'icon',
354 label: t('space.board.icon'),
355 placeholder: '',
356 optional: true,
357 maxLength: 16,
358 },
359 {
360 kind: 'select',
361 key: 'parent_dir_id',
362 label: t('space.repo.dir'),
363 initialValue: '',
364 options: [{ value: '', label: t('space.dir.root') }, ...dirOptions],
365 },
366 ]}
367 onSubmit={async (values) => {
368 await createBoard(space.name, {
369 name: String(values.name),
370 description: String(values.description).trim() || null,
371 icon: String(values.icon).trim() || null,
372 parent_dir_id: String(values.parent_dir_id) || null,
373 })
374 onListsChanged()
375 }}
376 onClose={closeDialog}
377 />
378 <FormModal
379 title={t('space.room.title')}
380 open={dialog === 'room'}
381 confirmLabel={t('common.action.create')}
382 fields={[
383 {
384 kind: 'text',
385 key: 'name',
386 label: t('space.room.name'),
387 placeholder: 'general',
388 hint: t('space.room.nameHint'),
389 sanitize: slug,
390 },
391 {
392 kind: 'select',
393 key: 'kind',
394 label: t('space.room.kind'),
395 initialValue: 'Text',
396 options: [
397 { value: 'Text', label: t('space.room.kind.text') },
398 { value: 'Voice', label: t('space.room.kind.voice') },
399 { value: 'Stage', label: t('space.room.kind.stage') },
400 ],
401 },
402 {
403 kind: 'select',
404 key: 'parent_id',
405 label: t('space.room.dir'),
406 initialValue: '',
407 options: [{ value: '', label: t('space.dir.root') }, ...dirOptions],
408 },
409 { kind: 'text', key: 'topic', label: t('space.room.topic'), optional: true },
410 {
411 kind: 'number',
412 key: 'user_limit',
413 label: t('space.room.userLimit'),
414 placeholder: t('space.room.noLimit'),
415 optional: true,
416 },
417 {
418 kind: 'number',
419 key: 'rate_limit_per_user',
420 label: t('space.room.rateLimit'),
421 placeholder: t('space.room.noLimit'),
422 optional: true,
423 },
424 { kind: 'toggle', key: 'is_private', label: t('space.room.private') },
425 ]}
426 onSubmit={async (values) => {
427 const userLimit = String(values.user_limit)
428 const rateLimit = String(values.rate_limit_per_user)
429 await createRoom(space.name, {
430 name: String(values.name),
431 kind: String(values.kind) as CreateRoomKind,
432 // Backend stores room.parent_id verbatim; the sidebar nests rooms
433 // whose parent matches a directory node inside the asset tree.
434 parent_id: String(values.parent_id) || null,
435 topic: String(values.topic).trim() || null,
436 is_private: Boolean(values.is_private),
437 ...(userLimit === '' ? {} : { user_limit: Number(userLimit) }),
438 ...(rateLimit === '' ? {} : { rate_limit_per_user: Number(rateLimit) }),
439 })
440 onListsChanged()
441 }}
442 onClose={closeDialog}
443 />
444 <FormModal
445 title={t('space.dir.title')}
446 description={t('space.dir.description')}
447 open={dialog === 'dir'}
448 confirmLabel={t('common.action.create')}
449 fields={[
450 {
451 kind: 'text',
452 key: 'name',
453 label: t('space.dir.name'),
454 placeholder: 'backend',
455 maxLength: 64,
456 },
457 {
458 kind: 'select',
459 key: 'parent_dir_id',
460 label: t('space.dir.parent'),
461 initialValue: '',
462 options: [{ value: '', label: t('space.dir.root') }, ...dirOptions],
463 },
464 ]}
465 onSubmit={async (values) => {
466 const parent = String(values.parent_dir_id)
467 await createSpaceDir(space.name, {
468 name: String(values.name),
469 parent_dir_id: parent || null,
470 })
471 onListsChanged()
472 }}
473 onClose={closeDialog}
474 />
475 {dialog === 'privacy' ? <PrivacyDialog onClose={closeDialog} /> : null}
476 <FormModal
477 title={t('space.leave.title')}
478 description={t('space.leave.sub', { space: space.name })}
479 open={dialog === 'leave'}
480 danger
481 confirmLabel={t('space.leave.title')}
482 fields={[]}
483 onSubmit={async () => {
484 await leaveSpace(space.name)
485 onLeft()
486 }}
487 onClose={closeDialog}
488 />
489 </div>
490 )
491}
492
493/** Stable fallback colour so communities without an uploaded banner stay recognisable. */
494function bannerHue(name: string): number {
495 let hash = 0
496 for (const character of name) hash = (hash * 31 + (character.codePointAt(0) ?? 0)) % 360
497 return hash
498}
499
500type CreateRoomKind = 'Text' | 'Voice' | 'Stage'
501function MenuItem({
502 icon: Icon,
503 label,
504 danger = false,
505 onClick,
506}: {
507 icon: LucideIcon
508 label: string
509 danger?: boolean
510 onClick: () => void
511}) {
512 return (
513 <button
514 type="button"
515 role="menuitem"
516 onClick={onClick}
517 className={`flex w-full items-center gap-3 rounded-md px-2.5 py-1.5 text-left text-[14px] font-medium transition-colors ${
518 danger ? 'text-[#f23f43] hover:bg-[#f23f43]/10' : 'text-shell-ink hover:bg-shell-active'
519 }`}
520 >
521 <span className="min-w-0 flex-1 truncate">{label}</span>
522 <Icon className={`h-[18px] w-[18px] shrink-0 ${danger ? '' : 'text-shell-ink'}`} aria-hidden />
523 </button>
524 )
525}
526
527function MenuDivider(): ReactNode {
528 return <div className="my-0.5 h-px bg-shell-hairline" />
529}
Added view/app/src/domains/space/management/ui/space-settings-modal.tsx view/app/src/domains/space/management/ui/space-settings-modal.tsx +220 −0 View view/app/src/domains/space/management/ui/space-settings-modal.tsx
@@ -0,0 +1,220 @@
1import { Alert } from '@app/components'
2import { Busy, SettingsModalShell, type SettingsSection } from '@app/domains/shared'
3import { useT } from '@app/i18n'
4import { spaceAdminKey, useAsync } from '@app/lib'
5import {
6 Ban,
7 ClipboardList,
8 Eraser,
9 Image,
10 KeyRound,
11 Settings,
12 Shield,
13 Trash2,
14 UserPlus,
15 UserRound,
16 Users,
17} from 'lucide-react'
18import { useEffect, useState } from 'react'
19import type { MySpace } from '../model/api'
20import { fetchSpace } from '../model/api'
21import { listSpaceGroups } from '../model/space-admin'
22import { BansSection } from './space-settings/bans-section'
23import { ChannelPermissionsSection } from './space-settings/channel-permissions-section'
24import { CleanupSection } from './space-settings/cleanup-section'
25import { DangerSection } from './space-settings/danger-section'
26import { GeneralSection } from './space-settings/general-section'
27import { ImagesSection } from './space-settings/images-section'
28import { JoinRequestsSection } from './space-settings/join-requests-section'
29import { JoinRulesSection } from './space-settings/join-rules-section'
30import { PERM } from './space-settings/meta'
31import { MyMembershipSection } from './space-settings/my-membership-section'
32import type { SpaceSectionProps } from './space-settings/props'
33import { RolesNav, RolesSection } from './space-settings/roles-section'
34
35export interface SpaceSettingsModalProps {
36 open: boolean
37 onClose: () => void
38 mySpace: MySpace
39 viewerId?: number
40 /** Deep-linked section to select on open (e.g. from a join-request notice). */
41 initialSection?: string
42 /** The space row changed — the rail tile and header need the fresh name/icon. */
43 onSpaceUpdated: () => void
44 /** The space was deleted — the shell reloads the list and leaves the space. */
45 onDeleted: () => void
46}
47
48/**
49 * Community settings, in the same large overlay as the user's own settings.
50 * `GET /spaces/{name}` is authoritative for the editable fields, and every
51 * section is gated by the permission its endpoints actually check on the
52 * server — `manage_roles` alone used to open a form that could only 403.
53 */
54export function SpaceSettingsModal({
55 open,
56 onClose,
57 mySpace,
58 viewerId,
59 initialSection,
60 onSpaceUpdated,
61 onDeleted,
62}: SpaceSettingsModalProps) {
63 const { t } = useT()
64 const [section, setSection] = useState('mine')
65 const [roleId, setRoleId] = useState<string | null>(null)
66 const [roleCreating, setRoleCreating] = useState(false)
67 const [rolesTick, setRolesTick] = useState(0)
68 const { permissions } = mySpace
69 const rolesOpen = section === 'roles'
70 const roleGroups = useAsync(
71 () => (open && rolesOpen ? listSpaceGroups(mySpace.space.name) : Promise.resolve([])),
72 [open, rolesOpen, mySpace.space.name, rolesTick],
73 { cacheKey: open && rolesOpen ? spaceAdminKey(mySpace.space.name, 'groups') : null },
74 )
75 const loaded = useAsync(
76 () => (open ? fetchSpace(mySpace.space.name) : Promise.resolve(null)),
77 [open, mySpace.space.name, mySpace.space.updated_at],
78 { cacheKey: open ? spaceAdminKey(mySpace.space.name, 'settings') : null },
79 )
80 // A cached full row from the previously opened space must not be mistaken
81 // for this one: only a payload naming this space counts as authoritative.
82 const full = loaded.data?.name === mySpace.space.name ? loaded.data : null
83 const space = full ?? mySpace.space
84
85 const ownerUid = space.owner?.user_id ?? (space as unknown as { owner_uid?: string }).owner_uid
86 const isOwner = Boolean(ownerUid && viewerId && ownerUid === viewerId)
87 const perms = new Set(permissions)
88
89 const communityGroup = t('space.settings.group.community')
90 const membersGroup = t('space.settings.group.members')
91 const visible: SettingsSection[] = [
92 { id: 'mine', label: t('space.settings.sections.mine'), icon: UserRound, group: communityGroup },
93 { id: 'general', label: t('space.settings.sections.general'), icon: Settings, group: communityGroup },
94 ...(perms.has(PERM.channels)
95 ? [{ id: 'images', label: t('space.settings.sections.images'), icon: Image, group: communityGroup }]
96 : []),
97 ...(perms.has(PERM.members)
98 ? [
99 {
100 id: 'members',
101 label: t('space.members.title'),
102 icon: Users,
103 group: membersGroup,
104 href: `/app/${encodeURIComponent(mySpace.space.name)}/members`,
105 },
106 {
107 id: 'join-requests',
108 label: t('space.settings.sections.joinRequests'),
109 icon: UserPlus,
110 group: membersGroup,
111 },
112 ]
113 : []),
114 ...(perms.has(PERM.roles)
115 ? [{ id: 'roles', label: t('space.roles.title'), icon: Shield, group: membersGroup }]
116 : []),
117 ...(perms.has(PERM.roles)
118 ? [{ id: 'join', label: t('space.settings.sections.join'), icon: ClipboardList, group: membersGroup }]
119 : []),
120 ...(perms.has(PERM.bans)
121 ? [{ id: 'bans', label: t('space.settings.sections.bans'), icon: Ban, group: membersGroup }]
122 : []),
123 ...(perms.has(PERM.channels) || perms.has(PERM.overwrites)
124 ? [{ id: 'channels', label: t('space.channelPermissions.title'), icon: KeyRound, group: membersGroup }]
125 : []),
126 // 对象清理是危险区里的一员,但比删空间频繁得多:单列一项,且仅所有者可见。
127 ...(isOwner
128 ? [{ id: 'cleanup', label: t('space.settings.sections.cleanup'), icon: Eraser, group: membersGroup }]
129 : []),
130 { id: 'danger', label: t('space.settings.danger'), icon: Trash2 },
131 ]
132
133 // A deep-linked section applies on each open, as long as this space's
134 // permissions actually expose it.
135 const paneSections = visible.filter((item) => !item.href)
136 useEffect(() => {
137 if (!open || !initialSection) return
138 if (paneSections.some((item) => item.id === initialSection)) setSection(initialSection)
139 // eslint-disable-next-line react-hooks/exhaustive-deps
140 }, [open, initialSection, mySpace.space.name])
141
142 // Losing the permission that owned the current section (or opening a
143 // different space) must not leave the content column blank.
144 useEffect(() => {
145 if (!paneSections.some((item) => item.id === section)) setSection(paneSections[0].id)
146 // eslint-disable-next-line react-hooks/exhaustive-deps
147 }, [section, permissions.join(','), mySpace.space.name])
148
149 if (!open) return null
150
151 const props: SpaceSectionProps = {
152 spaceName: mySpace.space.name,
153 space,
154 perms,
155 viewerId,
156 isOwner,
157 onSpaceUpdated,
158 }
159
160 return (
161 <SettingsModalShell
162 key={mySpace.space.name}
163 title={t('space.settings.title')}
164 sections={visible}
165 active={section}
166 contentToken={section === 'roles' ? `roles:${roleId ?? roleGroups.data?.[0]?.id ?? ''}` : section}
167 onSelect={setSection}
168 onClose={onClose}
169 navOverride={
170 rolesOpen ? (
171 <RolesNav
172 groups={roleGroups.data ?? []}
173 loading={roleGroups.loading}
174 selectedId={roleId ?? roleGroups.data?.[0]?.id ?? null}
175 creating={roleCreating}
176 onSelect={(id) => {
177 setRoleCreating(false)
178 setRoleId(id)
179 }}
180 onCreate={() => setRoleCreating(true)}
181 onBack={() => {
182 setRoleCreating(false)
183 setSection(paneSections[0]?.id ?? 'mine')
184 }}
185 />
186 ) : undefined
187 }
188 >
189 {loaded.error && !loaded.data ? (
190 <div className="mb-5">
191 <Alert tone="info">{t('space.settings.fullLoadFailed')}</Alert>
192 </div>
193 ) : null}
194 {section === 'mine' ? <MyMembershipSection {...props} /> : null}
195 {section === 'general' ? full || !loaded.loading ? <GeneralSection {...props} /> : <Busy /> : null}
196 {section === 'images' ? <ImagesSection {...props} /> : null}
197 {section === 'join-requests' ? <JoinRequestsSection {...props} /> : null}
198 {section === 'roles' ? (
199 <RolesSection
200 spaceName={mySpace.space.name}
201 groups={roleGroups.data ?? []}
202 loading={roleGroups.loading}
203 roleId={roleId ?? roleGroups.data?.[0]?.id ?? null}
204 creating={roleCreating}
205 onRefresh={() => setRolesTick((value) => value + 1)}
206 onCreated={(id) => {
207 setRoleCreating(false)
208 setRoleId(id ?? null)
209 setRolesTick((value) => value + 1)
210 }}
211 />
212 ) : null}
213 {section === 'join' ? <JoinRulesSection {...props} /> : null}
214 {section === 'bans' ? <BansSection {...props} /> : null}
215 {section === 'channels' ? <ChannelPermissionsSection {...props} /> : null}
216 {section === 'cleanup' ? <CleanupSection {...props} /> : null}
217 {section === 'danger' ? <DangerSection {...props} onDeleted={onDeleted} /> : null}
218 </SettingsModalShell>
219 )
220}
Added view/app/src/domains/space/management/index.ts view/app/src/domains/space/management/index.ts +11 −0 View view/app/src/domains/space/management/index.ts
@@ -0,0 +1,11 @@
1// Public API of features/space-management — the space header menu with its
2// mutation dialogs and the create-space modal.
3// Consumers import from '@app/domains/space/management', never from deeper files.
4
5export { acceptSpaceInvite, type DirNode, fetchSpaceDir, type MySpace, moveRoom, moveSpaceDir } from './model/api'
6export { CreateSpaceModal, type CreateSpaceModalProps } from './ui/create-space-modal'
7export { SpaceHeader, type SpaceHeaderProps } from './ui/space-header'
8export {
9 SpaceSettingsModal,
10 type SpaceSettingsModalProps,
11} from './ui/space-settings-modal'