feat(web): add a workspace members page and rework the workspace shell
- Move space members and invites out of the settings modal into a dedicated `:spaceName/members` route (`members-view`), with a privacy dialog for public/private visibility. - Rework the settings dialog shell: sections can carry an icon, be grouped under a caption, or link out to a full page, and a section can replace the list with its own sub-navigation. - Rebuild the workspace navigation as rail + sidebar columns, widen the sidebar to 280px and restyle the space rail and user panel. - Add a repo clone menu, polish the repo view/aside and issue board, and extend the en/zh-CN messages for issues, repo, settings, shell, space.
ZhenYi committed
Modified web/src/app/routes/router.tsx web/src/app/routes/router.tsx +1 −0 View web/src/app/routes/router.tsx
@@ -43,6 +43,7 @@
4343 { path: ':spaceName/chat/:roomId?' },
4444 { path: ':spaceName/board/:boardId?' },
4545 { path: ':spaceName/repo/:repoName?' },
46 { path: ':spaceName/members' },
4647 ],
4748 },
4849 { path: '/auth', element: <Navigate to="/auth/login" replace /> },
Modified web/src/app/styles/workspace.css web/src/app/styles/workspace.css +56 −24 View web/src/app/styles/workspace.css
@@ -12,14 +12,15 @@
1212 font-size: 14px;
1313 line-height: 1.6;
1414}
15.workspace-shell { --nav-rail: 76px; --nav-side: 248px; --nav-motion: 230ms; --nav-ease: cubic-bezier(.22,.68,.28,1); display: flex; width: 100%; height: 100dvh; overflow: hidden; background: var(--shell-sidebar); }
15.workspace-shell { --nav-rail: 76px; --nav-side: 280px; --nav-motion: 230ms; --nav-ease: cubic-bezier(.22,.68,.28,1); display: flex; width: 100%; height: 100dvh; overflow: hidden; background: var(--shell-sidebar); }
1616/*
1717 * Collapsing the sidebar animates the width of the navigation column and
1818 * clips it, instead of toggling `display: none` on the sidebar. The sidebar
1919 * keeps its own width inside that clip, so its contents never reflow while
2020 * the column closes — only the main column relayouts, which it must anyway.
2121 */
22.workspace-navigation { display: flex; flex-shrink: 0; overflow: hidden; width: calc(var(--nav-rail) + var(--nav-side)); transition: width var(--nav-motion) var(--nav-ease); }
22.workspace-navigation { display: flex; flex-direction: column; flex-shrink: 0; overflow: hidden; width: calc(var(--nav-rail) + var(--nav-side)); background: linear-gradient(to right, var(--shell-rail) 0 var(--nav-rail), var(--shell-sidebar) var(--nav-rail) 100%); transition: width var(--nav-motion) var(--nav-ease); }
23.workspace-navigation-columns { display: flex; flex: 1; min-height: 0; }
2324.workspace-rail { display: flex; width: var(--nav-rail); flex-shrink: 0; flex-direction: column; align-items: center; gap: 14px; overflow-y: auto; padding: 20px 0; background: var(--shell-rail); }
2425.workspace-sidebar { display: flex; flex-direction: column; width: var(--nav-side); border-right: 1px solid var(--shell-hairline); background: var(--shell-sidebar); transition: opacity calc(var(--nav-motion) * .45) ease calc(var(--nav-motion) * .4), visibility 0s; }
2526.workspace-sidebar-header { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 76px; padding: 14px 20px; border-bottom: 1px solid var(--shell-hairline); }
@@ -43,11 +44,15 @@
4344.space-community-avatar > img { width: 100%; height: 100%; object-fit: cover; }
4445.space-community-name { display: block; overflow: hidden; color: #fff; font-size: 15px; font-weight: 700; line-height: 1.2; text-overflow: ellipsis; white-space: nowrap; }
4546.space-community-members { display: inline-flex; align-items: center; min-height: 18px; margin-top: 4px; padding: 1px 6px; border: 1px solid rgb(255 255 255 / 20%); border-radius: 999px; background: rgb(255 255 255 / 16%); color: rgb(255 255 255 / 92%); font-size: 10px; font-weight: 700; line-height: 1; text-shadow: none; }
46.space-community-menu-trigger > svg { color: rgb(255 255 255 / 88%); }
47.space-community-members:hover { background: rgb(255 255 255 / 28%); }
48.space-community-menu-trigger svg { color: rgb(255 255 255 / 88%); }
4749.workspace-nav-caption { display: flex; align-items: center; justify-content: space-between; padding: 18px 16px 8px 20px; color: var(--shell-ink-muted); font-size: 12px; font-weight: 600; letter-spacing: .06em; }
4850.workspace-nav-pinned { flex-shrink: 0; padding: 10px 12px 2px; }
4951.workspace-nav { flex: 1; overflow-y: auto; padding: 0 12px 20px; }
50.workspace-sidebar-footer { display: flex; min-height: 80px; align-items: center; gap: 10px; margin: 0 12px; padding: 16px 6px; border-top: 1px solid var(--shell-hairline); }
52/* User bar at the foot of the navigation column: docked into the bottom-left
53 corner, floating clear of the rail above and of the channel list on its
54 right — the space it leaves shows the rail and sidebar surfaces as-is. */
55.workspace-navigation-footer { display: flex; min-height: 60px; flex-shrink: 0; align-items: center; gap: 10px; margin: 8px 8px 0 0; padding: 12px 16px; border-top-right-radius: 12px; background: var(--shell-surface); box-shadow: var(--shell-shadow); }
5156.workspace-main { position: relative; display: flex; flex: 1; min-width: 0; min-height: 0; flex-direction: column; background: var(--shell-surface); }
5257.workspace-main:focus { outline: none; }
5358.workspace-icon-button { display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; flex-shrink: 0; border-radius: 10px; color: var(--shell-ink-muted); transition: background 160ms, color 160ms; }
@@ -55,9 +60,9 @@
5560/* Three footer controls instead of two: the row tightens to keep the name legible. */
5661.workspace-icon-button.is-compact { width: 32px; height: 32px; border-radius: 9px; }
5762/* A toggle that is "off" (muted microphone, deafened headphones) reads red. */
58.workspace-icon-button.is-off { color: var(--error-foreground); }
63.workspace-icon-button.is-off { color: var(--error-foreground); background: color-mix(in srgb, var(--destructive) 14%, transparent); }
5964.workspace-icon-button.is-off:hover {
60 background: color-mix(in srgb, var(--destructive) 12%, transparent);
65 background: color-mix(in srgb, var(--destructive) 22%, transparent);
6166 color: var(--error-foreground);
6267}
6368.workspace-collapse { width: 28px; height: 28px; }
@@ -72,6 +77,7 @@
7277.rail-only .workspace-sidebar { display: none; }
7378.navigation-collapsed .workspace-navigation { width: var(--nav-rail); }
7479.navigation-collapsed .workspace-sidebar { opacity: 0; visibility: hidden; transition: opacity calc(var(--nav-motion) * .45) ease, visibility 0s linear calc(var(--nav-motion) * .45); }
80.navigation-collapsed .workspace-navigation-footer, .rail-only .workspace-navigation-footer { display: none; }
7581/*
7682 * The collapsed-nav expand button floats over the top-left of the content
7783 * column, so the bar underneath it reserves that gutter. The button is
@@ -136,12 +142,19 @@
136142.shell-top-icon { display: grid; place-items: center; width: 38px; height: 38px; flex-shrink: 0; border: 1px solid var(--shell-hairline); border-radius: 12px; color: var(--shell-ink-muted); background: var(--shell-field); }
137143.shell-top-icon svg { color: inherit; }
138144.shell-top-actions { display: flex; flex-shrink: 0; align-items: center; gap: 10px; color: var(--shell-ink-muted); }
139.workspace-nav-row { position: relative; min-height: 40px; margin-bottom: 4px; gap: 10px; border-radius: 10px; transition: background 160ms, color 160ms; }
140.workspace-nav-row[aria-current="page"] { color: var(--shell-ink); background: var(--shell-surface); font-weight: 600; box-shadow: inset 0 0 0 1px var(--shell-hairline); }
145.workspace-nav-row { position: relative; min-height: 32px; margin-bottom: 2px; gap: 10px; border-radius: 8px; transition: background 160ms, color 160ms; }
146.workspace-nav-row:is(a, button, [data-kind="folder"]):hover { background: var(--shell-active); }
147/* Selected rows sit two shades below the hover gray instead of flipping to white. */
148.workspace-nav-row[aria-current="page"] { color: var(--shell-ink); background: color-mix(in srgb, var(--shell-ink) 4%, var(--shell-tile)); font-weight: 600; }
141149.workspace-nav-row[aria-current="page"] > span:first-child { color: var(--shell-ink-muted); }
142150.workspace-nav-row[data-kind="folder"] { margin-top: 12px; }
143.workspace-rail a, .workspace-rail button { border-radius: 16px; transition: background 160ms, color 160ms, border-radius 160ms, box-shadow 160ms; }
144.workspace-rail a[aria-current="page"] { background: var(--shell-surface); color: var(--shell-ink); box-shadow: inset 0 0 0 1px var(--shell-hairline); }
151/* Rail tiles rest as circles and square off on hover or when they own the route. */
152.workspace-rail a, .workspace-rail button { border-radius: 9999px; transition: background 160ms, color 160ms, border-color 160ms, border-radius 200ms, box-shadow 160ms; }
153.workspace-rail a:hover, .workspace-rail button:hover, .workspace-rail a[aria-current="page"] { border-radius: 16px; }
154.workspace-rail a[aria-current="page"] { color: var(--shell-ink); }
155/* The dashed create / discover ring follows the same morph, without changing its fill. */
156.rail-dashed-ring-shape { rx: 22px; transition: rx 200ms; }
157.workspace-rail a:hover .rail-dashed-ring-shape, .workspace-rail button:hover .rail-dashed-ring-shape, .workspace-rail a[aria-current="page"] .rail-dashed-ring-shape { rx: 14px; }
145158/* Keyboard focus for controls. */
146159.workspace-shell :is(button, a, summary, [role="button"], [role="option"]):focus-visible, .workspace-overlay :is(button, a, summary, [role="button"], [role="option"]):focus-visible { outline: 2px solid color-mix(in srgb, var(--shell-focus) 70%, transparent); outline-offset: 2px; }
147160
@@ -168,16 +181,33 @@
168181.workspace-dialog :is(input, select, textarea) { border-width: 1px; border-radius: 10px; font-size: 14px; }
169182.workspace-dialog :is(input:not([type="checkbox"]), select) { min-height: 42px; }
170183.workspace-dialog .dialog-close { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 10px; background: var(--shell-field); }
171.settings-dialog { display: flex; position: relative; width: min(1060px, 94vw); height: min(820px, 90dvh); overflow: hidden; border: 1px solid var(--shell-hairline); border-radius: 22px; background: var(--shell-surface); box-shadow: 0 24px 80px rgb(15 17 19 / 22%); }
172.settings-nav { display: flex; width: 220px; flex-shrink: 0; flex-direction: column; gap: 5px; overflow-y: auto; padding: 24px 16px; background: var(--shell-sidebar); border-right: 1px solid var(--shell-hairline); }
173.settings-nav > p { padding: 0 12px 18px; font-size: 13px; font-weight: 600; color: var(--shell-ink-muted); }
174.settings-nav > button { min-height: 40px; padding: 8px 12px; border-radius: 10px; font-size: 14px; }
175.settings-nav > button[aria-current="page"] { background: var(--shell-surface); color: var(--shell-ink); font-weight: 600; box-shadow: inset 0 0 0 1px var(--shell-hairline); }
176.settings-content { flex: 1; min-width: 0; overflow-y: auto; padding: 38px 42px 60px; }
177.settings-content > section { max-width: 660px; }
184.settings-dialog { display: flex; position: relative; width: min(1320px, calc(100vw - 3rem)); height: min(860px, calc(100dvh - 3rem)); overflow: hidden; border: 1px solid var(--shell-hairline); border-radius: 18px; background: var(--shell-surface); box-shadow: 0 24px 80px rgb(15 17 19 / 22%); }
185.settings-nav { display: flex; width: 264px; flex-shrink: 0; flex-direction: column; gap: 2px; overflow-y: auto; padding: 16px 12px 20px; background: var(--shell-sidebar); border-right: 1px solid var(--shell-hairline); }
186.settings-nav-body { display: flex; min-height: 0; flex: 1; flex-direction: column; gap: 2px; }
187.settings-nav-title { padding: 8px 10px 12px; font-size: 16px; font-weight: 700; color: var(--shell-ink); }
188.settings-nav-caption { padding: 14px 10px 4px; font-size: 11px; font-weight: 700; letter-spacing: .06em; color: var(--shell-ink-muted); }
189.settings-nav-item { display: flex; width: 100%; min-height: 36px; align-items: center; gap: 10px; padding: 7px 10px; border-radius: 8px; color: var(--shell-ink-muted); font-size: 14px; text-align: left; transition: background 160ms, color 160ms; }
190.settings-nav-item:hover { background: var(--shell-active); color: var(--shell-ink); }
191.settings-nav-item[aria-current="page"] { background: var(--shell-active); color: var(--shell-ink); font-weight: 600; }
192/* Destructive sections read red wherever they sit in a settings nav. */
193.settings-nav-item.settings-nav-danger, .settings-nav-item.settings-nav-danger[aria-current="page"] { color: #f23f43; }
194.settings-nav-item.settings-nav-danger:hover { background: color-mix(in srgb, #f23f43 10%, transparent); color: #f23f43; }
195.settings-pane { display: flex; flex: 1; min-width: 0; flex-direction: column; }
196.settings-pane-header { display: flex; min-height: 60px; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 16px 10px 28px; border-bottom: 1px solid var(--shell-hairline); }
197.settings-pane-header h2 { min-width: 0; overflow: hidden; font-size: 17px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
198.settings-content { flex: 1; min-width: 0; min-height: 0; overflow-y: auto; scrollbar-gutter: stable; padding: 32px 40px 56px; }
199.settings-content > section { max-width: 720px; }
200/* Swapped panes cross-fade in place: no movement, no empty flash. */
201.settings-swap { position: relative; }
202.settings-swap-out { position: absolute; top: 0; left: 0; right: 0; pointer-events: none; animation: settings-swap-out 200ms ease both; }
203.settings-swap-in { animation: settings-swap-in 200ms ease both; }
204@keyframes settings-swap-in { from { opacity: 0; } to { opacity: 1; } }
205@keyframes settings-swap-out { from { opacity: 1; } to { opacity: 0; } }
206/* The pane header already names the section, so the body keeps only its note. */
207.settings-content > section > .settings-section-head > h2 { display: none; }
178208.settings-content :is(input, select, textarea) { border-width: 1px; border-radius: 10px; min-height: 40px; font-size: 14px; }
179209.settings-content input[type="checkbox"] { min-height: 0; }
180.settings-close { position: absolute; right: 16px; top: 16px; background: var(--shell-field); }
210.settings-close { flex-shrink: 0; background: var(--shell-field); }
181211/*
182212 * Settings lists (user / space / repo): one card per entry, its inline editor
183213 * revealed below. Surfaces stay neutral — selection is shown by weight and a
@@ -254,7 +284,7 @@
254284.palette-restore { color: var(--shell-ink); text-decoration: underline; text-underline-offset: 2px; }
255285
256286@media (max-width: 760px) {
257 .workspace-shell { flex-direction: column; }
287 .workspace-shell { --nav-rail: 64px; flex-direction: column; }
258288 .workspace-mobile-bar { display: flex; min-height: 52px; flex-shrink: 0; align-items: center; gap: 10px; padding: 0 12px; border-bottom: 1px solid var(--shell-hairline); }
259289 .workspace-navigation, .navigation-collapsed .workspace-navigation { position: fixed; inset: 0 auto 0 0; z-index: 45; width: min(338px, calc(100vw - 44px)); visibility: hidden; transform: translateX(-100%); transition: transform var(--nav-motion) var(--nav-ease), visibility 0s linear var(--nav-motion); }
260290 .navigation-open .workspace-navigation { visibility: visible; transform: translateX(0); box-shadow: 20px 0 60px rgb(15 17 19 / 18%); transition-delay: 0s, 0s; }
@@ -273,11 +303,13 @@
273303 .shell-top-icon { width: 32px; height: 32px; border-radius: 10px; }
274304 .workspace-overlay { padding: 12px; }
275305 .workspace-dialog { padding: 22px; max-height: calc(100dvh - 24px); }
276 .settings-dialog { width: 100%; height: calc(100dvh - 24px); border-radius: 18px; flex-direction: column; }
277 .settings-nav { width: 100%; flex-direction: row; padding: 56px 14px 12px; border-right: 0; border-bottom: 1px solid var(--shell-hairline); overflow-x: auto; flex-shrink: 0; }
278 .settings-nav > p { position: absolute; top: 20px; left: 16px; padding: 0; }
279 .settings-nav > button { width: auto; flex-shrink: 0; white-space: nowrap; }
280 .settings-content { padding: 24px 20px 40px; }
306 .settings-dialog { width: 100%; height: calc(100dvh - 24px); border-radius: 16px; flex-direction: column; }
307 .settings-nav { width: 100%; flex-direction: row; align-items: center; gap: 4px; padding: 12px 14px; border-right: 0; border-bottom: 1px solid var(--shell-hairline); overflow-x: auto; flex-shrink: 0; }
308 .settings-nav-title { padding: 0 10px 0 2px; font-size: 15px; white-space: nowrap; }
309 .settings-nav-caption { display: none; }
310 .settings-nav-item { width: auto; flex-shrink: 0; white-space: nowrap; }
311 .settings-pane-header { min-height: 56px; padding: 10px 12px 10px 18px; }
312 .settings-content { padding: 20px 18px 32px; }
281313 .settings-row { flex-wrap: wrap; align-items: flex-start; }
282314 .settings-card > .settings-row { padding: 12px; }
283315 .settings-subpanel { padding: 12px; }
Modified web/src/features/issue-board/ui/board-polish.css web/src/features/issue-board/ui/board-polish.css +129 −28 View web/src/features/issue-board/ui/board-polish.css
@@ -95,25 +95,124 @@
9595.issue-board-skeleton { animation: issue-board-pulse 1.5s ease-in-out infinite alternate; }
9696@keyframes issue-board-pulse { to { opacity: .5; } }
9797
98/* Issue detail and the feature's create/manage dialogs. */
99.issue-detail { max-width: 1200px; margin: 12px auto 0; gap: 20px; }
100.issue-detail-actions { gap: 10px; }
101.issue-detail-actions button { min-height: 34px; border-radius: 8px; padding-inline: 10px; }
102.issue-detail-layout { gap: 22px; }
103.issue-detail-layout > div { min-width: 320px; }
104.issue-detail article, .issue-detail section, .issue-detail-sidebar { border-width: 1px; border-radius: 14px; }
105.issue-detail-article { padding: 26px; }
106.issue-detail-article h2 { font-size: 24px; line-height: 1.5; font-weight: 650; letter-spacing: -.025em; }
107.issue-detail-article > p { line-height: 1.9; }
108.issue-detail-article > p:last-child { border-top-width: 1px; padding-top: 17px; margin-top: 24px; font-size: 11px; }
109.issue-detail-sidebar { width: 254px; padding: 20px; background: var(--shell-surface); }
110.issue-detail-sidebar > div + div { margin-top: 21px; }
111.issue-detail-sidebar .board-filter-menu { width: 100%; }
112.issue-detail-sidebar .board-filter-trigger { width: 100%; justify-content: space-between; }
113.issue-detail-sidebar input[type='date'] { height: 36px; border-width: 1px; border-radius: 8px; }
114.issue-detail textarea { border-width: 1px; border-radius: 9px; padding: 13px; line-height: 1.8; }
115.issue-detail section:has(textarea) { padding: 22px; }
116.issue-detail section h3 { font-size: 15px; }
98/*
99 * Issue detail, Linear-style: the page is the canvas (no framing cards),
100 * comments are the only cards, and the right rail is a list of plain rows.
101 */
102.issue-detail { display: flex; max-width: 1180px; flex-direction: column; gap: 14px; margin: 6px auto 64px; }
103.issue-detail-notice { padding: 6px 10px; border-radius: 8px; background: var(--shell-field); color: var(--shell-ink-muted); font-size: 12.5px; }
104
105.issue-detail-topline { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
106.issue-detail-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-left: auto; }
107.issue-action { display: inline-flex; align-items: center; gap: 6px; height: 30px; padding-inline: 9px; border-radius: 7px; color: var(--shell-ink-muted); font-size: 12.5px; transition: background 140ms, color 140ms; }
108.issue-action:hover { background: var(--shell-field); color: var(--shell-ink); }
109.issue-action-strong { color: var(--shell-ink); font-weight: 600; }
110.issue-action-strong:hover { background: var(--shell-active); }
111.issue-action-danger { height: 30px; padding-inline: 9px; border-radius: 7px; color: #b42318; font-size: 12.5px; }
112.issue-action-danger:hover { background: color-mix(in srgb, #e5484d 10%, transparent); color: #b42318; }
113.issue-action-danger[data-armed='true'] { background: #e5484d; color: #fff; font-weight: 600; }
114
115.issue-detail-layout { display: flex; align-items: flex-start; gap: 48px; }
116.issue-detail-main { display: flex; min-width: 0; max-width: 780px; flex: 1 1 auto; flex-direction: column; gap: 22px; }
117.issue-detail-rail { display: flex; width: 268px; flex-shrink: 0; flex-direction: column; gap: 20px; margin-left: auto; }
118
119.issue-detail-key { display: flex; align-items: center; gap: 8px; }
120.issue-detail-number { color: var(--shell-ink-muted); font-family: var(--font-mono); font-size: 12px; }
121.issue-detail-title { margin-top: 10px; font-size: 24px; font-weight: 650; line-height: 1.35; letter-spacing: -.02em; }
122.issue-detail-desc { margin-top: 10px; font-size: 13.5px; line-height: 1.75; white-space: pre-wrap; word-break: break-word; }
123.issue-detail-desc.is-empty { color: var(--shell-ink-muted); }
124.issue-detail-byline { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 16px; color: var(--shell-ink-muted); font-size: 12px; }
125
126.issue-feed { display: flex; flex-direction: column; gap: 14px; padding-top: 18px; border-top: 1px solid var(--shell-hairline); }
127.issue-feed-head { display: flex; min-height: 30px; align-items: center; gap: 8px; }
128.issue-feed-head h2 { font-size: 13px; font-weight: 600; }
129.issue-feed-count { color: var(--shell-ink-muted); font-size: 12px; }
130.issue-feed-head-actions { margin-left: auto; }
131.issue-feed-note { color: var(--shell-ink-muted); font-size: 12.5px; }
132.issue-feed-more { display: inline-flex; align-self: flex-start; align-items: center; gap: 6px; height: 28px; padding-inline: 8px; border-radius: 7px; color: var(--shell-ink-muted); font-size: 12px; transition: background 140ms, color 140ms; }
133.issue-feed-more:hover { background: var(--shell-field); color: var(--shell-ink); }
134
135.issue-event { display: grid; grid-template-columns: 20px minmax(0, 1fr); align-items: start; gap: 10px; }
136.issue-event-icon { display: grid; width: 20px; height: 20px; place-items: center; }
137.issue-event-dot { display: grid; width: 20px; height: 20px; place-items: center; border-radius: 50%; background: var(--shell-field); color: var(--shell-ink-muted); }
138.issue-event-body { min-width: 0; padding-top: 1px; color: var(--shell-ink-muted); font-size: 12.5px; line-height: 1.6; }
139.issue-event-actor { color: var(--shell-ink); font-weight: 550; }
140.issue-event-extra { color: var(--shell-ink-muted); }
141.issue-event-time { color: var(--shell-ink-muted); font-size: 11.5px; }
142
143.comment-card { padding: 14px 16px; border: 1px solid var(--shell-hairline); border-radius: 10px; background: var(--shell-surface); box-shadow: 0 1px 2px rgb(20 30 60 / 4%); }
144.comment-head { display: flex; align-items: center; gap: 8px; }
145.comment-author { font-size: 12.5px; font-weight: 600; }
146.comment-time { color: var(--shell-ink-muted); font-size: 11.5px; }
147.comment-actions { display: flex; align-items: center; gap: 2px; margin-left: auto; }
148.comment-action { padding: 3px 6px; border-radius: 6px; color: var(--shell-ink-muted); font-size: 11.5px; transition: background 140ms, color 140ms; }
149.comment-action:hover { background: var(--shell-field); color: var(--shell-ink); }
150.comment-body { margin-top: 8px; font-size: 13px; line-height: 1.75; white-space: pre-wrap; word-break: break-word; }
151.comment-error { margin-top: 6px; color: #b42318; font-size: 12px; }
152.comment-editor { display: flex; flex-direction: column; gap: 8px; margin-top: 10px; }
153.comment-input { width: 100%; min-height: 44px; padding: 8px 10px; border: 1px solid var(--shell-hairline); border-radius: 8px; background: var(--shell-field); font-size: 13px; line-height: 1.7; outline: none; resize: vertical; transition: border-color 150ms, background 150ms; }
154.comment-input:focus { border-color: var(--shell-tile); background: var(--shell-surface); }
155.comment-replies { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; }
156.comment-replies .comment-card { border: 0; background: var(--shell-board); box-shadow: none; }
157.comment-reply-row { display: flex; align-items: center; gap: 8px; margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--shell-hairline); }
158.comment-reply-input { min-width: 0; flex: 1; height: 28px; padding-inline: 2px; font-size: 12.5px; outline: none; }
159.comment-send { display: grid; width: 26px; height: 26px; flex-shrink: 0; place-items: center; border-radius: 7px; color: var(--shell-ink-muted); transition: background 140ms, color 140ms, opacity 140ms; }
160.comment-send:disabled { opacity: .45; }
161.comment-send:not(:disabled) { background: var(--shell-action); color: #fff; }
162.comment-send:not(:disabled):hover { filter: brightness(1.08); }
163/* One box owns the border: the textarea inside it stays bare. */
164.issue-composer { display: flex; flex-direction: column; gap: 8px; padding: 12px 14px 10px; transition: border-color 150ms, box-shadow 150ms; }
165.issue-composer:focus-within { border-color: var(--shell-tile); box-shadow: 0 1px 2px rgb(20 30 60 / 4%), 0 0 0 3px color-mix(in srgb, var(--shell-focus) 16%, transparent); }
166.issue-composer .comment-input { min-height: 42px; padding: 0; border: 0; border-radius: 0; background: transparent; resize: none; }
167.issue-composer .comment-input:focus { border: 0; background: transparent; }
168.issue-detail .issue-composer .comment-input:focus-visible { outline: none; }
169.comment-foot { display: flex; align-items: center; justify-content: flex-end; gap: 8px; }
170
171/* Right rail: grouped rows instead of boxes. */
172.issue-rail-group { display: flex; flex-direction: column; gap: 4px; }
173.issue-rail-title { padding-inline: 8px; color: var(--shell-ink-muted); font-size: 11.5px; font-weight: 550; letter-spacing: .02em; }
174.issue-rail-rows { display: flex; flex-direction: column; }
175.issue-rail-labels { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 2px 8px; }
176.issue-rail-empty { color: var(--shell-ink-muted); font-size: 12px; }
177.issue-rail-error { color: #b42318; font-size: 11.5px; }
178
179.issue-prop-row { display: flex; width: 100%; height: 30px; align-items: center; gap: 8px; padding-inline: 8px; border-radius: 7px; color: var(--shell-ink); font-size: 12.5px; transition: background 140ms; }
180.issue-prop-row:hover { background: var(--shell-field); }
181.issue-prop-icon { flex-shrink: 0; color: var(--shell-ink-muted); }
182.issue-prop-label { flex-shrink: 0; color: var(--shell-ink-muted); }
183.issue-prop-value { display: flex; min-width: 0; align-items: center; gap: 6px; margin-left: auto; }
184.issue-prop-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
185.issue-prop-text.is-empty { color: var(--shell-ink-muted); }
186.issue-prop-chevron { flex-shrink: 0; color: var(--shell-ink-muted); opacity: 0; transition: opacity 140ms; }
187.issue-prop-row:hover .issue-prop-chevron, .issue-prop-row[aria-expanded='true'] .issue-prop-chevron { opacity: .7; }
188.issue-prop-clear { display: grid; width: 18px; height: 18px; flex-shrink: 0; place-items: center; border-radius: 5px; color: var(--shell-ink-muted); }
189.issue-prop-clear:hover { background: var(--shell-active); color: var(--shell-ink); }
190.issue-prop-date { max-width: 134px; height: 28px; padding: 0; color: inherit; font-size: 12.5px; text-align: right; outline: none; }
191.issue-prop-date.is-empty { color: var(--shell-ink-muted); }
192.issue-prop-date::-webkit-calendar-picker-indicator { opacity: .45; cursor: pointer; }
193.issue-prop-date:hover::-webkit-calendar-picker-indicator { opacity: .75; }
194.issue-prop-popover { position: absolute; top: calc(100% + 4px); right: 0; z-index: 40; width: 248px; max-height: 280px; overflow-y: auto; }
195.issue-prop-add { display: inline-flex; align-items: center; gap: 4px; height: 24px; padding-inline: 6px; border-radius: 6px; color: var(--shell-ink-muted); font-size: 12px; transition: background 140ms, color 140ms; }
196.issue-prop-add:hover { background: var(--shell-field); color: var(--shell-ink); }
197.issue-check { display: grid; width: 14px; height: 14px; flex-shrink: 0; place-items: center; border: 1px solid var(--shell-tile); border-radius: 4px; color: #fff; transition: background 120ms, border-color 120ms; }
198.issue-check.is-on { border-color: var(--shell-ink); background: var(--shell-ink); }
199
200.issue-pull-links { display: flex; flex-direction: column; }
201.issue-pull-row { display: flex; min-height: 30px; align-items: center; gap: 8px; padding-inline: 8px; border-radius: 7px; color: var(--shell-ink); font-size: 12.5px; transition: background 140ms; }
202.issue-pull-row:hover { background: var(--shell-field); }
203.issue-pull-state { color: var(--shell-ink-muted); }
204.issue-link-button { align-self: flex-start; height: 28px; margin: 4px 0 0 8px; padding-inline: 10px; border-radius: 7px; background: var(--shell-active); font-size: 12px; font-weight: 550; transition: background 140ms; }
205.issue-link-button:not(:disabled):hover { background: var(--shell-tile); }
206.issue-link-button:disabled { opacity: .5; }
207
208/* Bare filter trigger = a rail row that opens the picker. */
209.board-filter-trigger-bare { width: 100%; height: 30px; justify-content: flex-start; gap: 8px; padding-inline: 8px; border: 0; border-radius: 7px; background: transparent; color: var(--shell-ink); }
210.board-filter-trigger-bare:hover, .board-filter-trigger-bare[aria-expanded='true'] { background: var(--shell-field); }
211.board-filter-trigger-bare .board-filter-leading,
212.board-filter-trigger-bare .board-filter-leading > span { color: var(--shell-ink-muted); }
213.board-filter-trigger-bare .board-filter-trigger-value { max-width: 150px; margin-left: auto; }
214.board-filter-trigger-bare .board-filter-chevron { opacity: 0; transition: opacity 140ms; }
215.board-filter-trigger-bare:hover .board-filter-chevron, .board-filter-trigger-bare[aria-expanded='true'] .board-filter-chevron { opacity: .7; }
117216.issue-dialog-overlay { z-index: 60; background: rgb(22 28 46 / 42%); backdrop-filter: blur(5px); }
118217.issue-dialog { max-height: min(90dvh, 880px); padding: 26px; border-width: 1px; border-radius: 20px; box-shadow: 0 24px 80px rgb(10 20 40 / 22%); outline: none; }
119218.issue-dialog-header { margin-bottom: 22px; }
@@ -138,29 +237,31 @@
138237.issue-dialog [role='tab'][aria-selected='true'] { background: var(--shell-field); border-radius: 7px 7px 0 0; }
139238:is(.board-filters, .issue-board-body, .issue-board-empty, .issue-dialog, .issue-detail) :is(button, a, input, select, textarea):focus-visible { outline: 2px solid var(--shell-ink-muted); outline-offset: 3px; }
140239:is(.board-filters, .issue-dialog, .issue-detail) :is(input, textarea):focus-visible { outline: none; }
240.issue-detail :is(button, a, input, textarea):focus-visible { outline-offset: 1px; }
241.issue-detail .comment-input:focus-visible, .issue-detail .comment-reply-input:focus-visible { outline: 2px solid var(--shell-ink-muted); outline-offset: 1px; }
141242
142243@media (max-width: 1024px) {
143244 .board-filters { flex-basis: 100%; }
144245 .board-toolbar-actions { margin-left: 0; }
145 .issue-detail-sidebar { width: 220px; }
246 .issue-detail-layout { gap: 32px; }
247 .issue-detail-rail { width: 240px; }
146248}
147249@media (max-width: 700px) {
148250 .board-toolbar:has(.board-filters) { gap: 12px; padding-block: 14px; }
149251 .board-search { max-width: none; flex-basis: 100%; }
150252 .board-filters { gap: 7px; }
151 .board-filter-trigger { height: 34px; }
152 .board-filter-trigger > span { max-width: 100px; }
253 .board-filter-trigger:not(.board-filter-trigger-bare) { height: 34px; }
254 .board-filter-trigger:not(.board-filter-trigger-bare) > span { max-width: 100px; }
153255 .board-filter-popover { width: 205px; }
154256 .issue-board-column { width: min(284px, 78vw); }
155257 .issue-board-columns { gap: 12px; }
156258 .issue-board-empty { min-height: 320px; padding-inline: 18px; }
157259 .issue-board-empty > p:first-of-type { font-size: 17px; }
158 .issue-detail-layout { flex-direction: column; }
159 .issue-detail-layout > div { width: 100%; min-width: 0; }
160 .issue-detail-sidebar { width: 100%; }
161 .issue-detail-actions > div { flex-wrap: wrap; margin-left: 0; flex-basis: 100%; }
162 .issue-detail-article { padding: 20px; }
163 .issue-detail-article h2 { font-size: 21px; }
260 .issue-detail-layout { flex-direction: column; gap: 26px; }
261 .issue-detail-main { width: 100%; max-width: none; }
262 .issue-detail-rail { width: 100%; }
263 .issue-detail-actions { flex-basis: 100%; margin-left: 0; }
264 .issue-detail-title { font-size: 21px; }
164265 .issue-dialog { max-height: 92dvh; padding: 20px; border-radius: 16px; }
165266 .issue-dialog-overlay { padding: 12px; }
166267 .issue-form-metadata { grid-template-columns: 1fr; }
Modified web/src/features/issue-board/ui/issue-detail.tsx web/src/features/issue-board/ui/issue-detail.tsx +483 −319 View web/src/features/issue-board/ui/issue-detail.tsx
@@ -4,11 +4,27 @@
44 * and refreshes only this view's data — no document reload, and the board
55 * behind it keeps its filters.
66 */
7import { useEffect, useState } from 'react'
8import { ArrowLeft, Link2, Loader2, Pencil, Tag, Users } from 'lucide-react'
7import { useEffect, useRef, useState } from 'react'
8import {
9 ArrowLeft,
10 ArrowUp,
11 Bell,
12 Calendar,
13 Check,
14 ChevronDown,
15 Flag,
16 FolderGit2,
17 GitPullRequest,
18 Loader2,
19 Milestone as MilestoneIcon,
20 Pencil,
21 Plus,
22 Users,
23 X,
24} from 'lucide-react'
925import { fmtTime, issueKey, spaceReposPickKey, useAsync } from '@/shared/lib'
10import { t as tr, useT } from '@/shared/i18n'
11import { ErrorPanel, PanelNote } from '@/shared/ui'
26import { parseDateValue, t as tr, useT } from '@/shared/i18n'
27import { ErrorPanel } from '@/shared/ui'
1228import {
1329 PRIORITIES,
1430 bindLabel,
@@ -31,9 +47,10 @@
3147 updateIssue,
3248 type BoardRef,
3349 type IssueComment,
50 type UserDto,
3451 type WatchMode,
3552} from '@/entities/issue'
36import { DangerButton, FilterMenu, LabelChip, PriorityBadge, StateBadge, UserAvatar } from './parts'
53import { AvatarStack, DangerButton, FilterMenu, LabelChip, PriorityBadge, StateBadge, UserAvatar } from './parts'
3754import {
3855 listIssuePulls,
3956 listPulls,
@@ -50,6 +67,20 @@
5067 ignore: 'issue.watch.ignore',
5168}
5269
70/** Entries of the merged activity stream: change events + comment threads. */
71type FeedEntry =
72 | { kind: 'event'; key: string; at: number; event: import('@/entities/issue').IssueEvent }
73 | { kind: 'comment'; key: string; at: number; node: import('@/entities/issue').CommentNode }
74
75/** Timeline rows shown before the "show earlier" toggle appears. */
76const FEED_WINDOW = 6
77
78/** Sort key that tolerates the `+00:00:00` offsets Rust emits. */
79function feedStamp(value: string): number {
80 const ms = parseDateValue(value).getTime()
81 return Number.isNaN(ms) ? 0 : ms
82}
83
5384export interface IssueDetailProps {
5485 boardRef: BoardRef
5586 /** Board-scoped number or uuid, straight from the route. */
@@ -68,7 +99,7 @@
6899 const { t, tc } = useT()
69100 const [retry, setRetry] = useState(0)
70101 const [notice, setNotice] = useState<string | null>(null)
71 const [showActivity, setShowActivity] = useState(false)
102 const [feedExpanded, setFeedExpanded] = useState(false)
72103 // `retry` is this view's own refresh, `reloadKey` the page's (another tab of
73104 // the app changed the board, or a label was renamed in the manage dialog).
74105 const stamp = [boardRef.space, boardRef.board, issueId, retry, reloadKey]
@@ -136,31 +167,62 @@
136167
137168 const boundLabels = labels.data ?? []
138169 const milestoneOptions = (data.milestones.data?.items ?? []).filter(m => !m.is_closed)
170 const milestoneName = issue.milestone_id
171 ? (milestoneOptions.find(m => m.id === issue.milestone_id)?.name ?? t('issue.board.milestoneUnknown'))
172 : ''
173
174 // Linear reads one issue as a single stream: comments and change events share
175 // a timeline. A comment's own event is dropped when its card is already in
176 // the stream, so the thread is never shown twice.
177 const rootNodes = comments.data ?? []
178 const commentIds = new Set<string>()
179 const collectCommentIds = (node: import('@/entities/issue').CommentNode) => {
180 commentIds.add(node.comment.id)
181 node.replies?.forEach(collectCommentIds)
182 }
183 rootNodes.forEach(collectCommentIds)
184 const eventItems = (events.data?.items ?? []).filter(
185 event =>
186 !(
187 event.comment_id &&
188 commentIds.has(event.comment_id) &&
189 (event.event_type === 'comment_created' || event.event_type === 'comment_updated')
190 ),
191 )
192 const feed: FeedEntry[] = [
193 ...rootNodes.map(node => ({
194 kind: 'comment' as const,
195 key: `c-${node.comment.id}`,
196 at: feedStamp(node.comment.created_at),
197 node,
198 })),
199 ...eventItems.map(event => ({
200 kind: 'event' as const,
201 key: `e-${event.id}`,
202 at: feedStamp(event.created_at),
203 event,
204 })),
205 ].sort((a, b) => a.at - b.at)
206 const earlier = Math.max(0, feed.length - FEED_WINDOW)
207 const visibleFeed = feedExpanded ? feed : feed.slice(-FEED_WINDOW)
139208
140209 return (
141 <div className="issue-detail flex flex-col gap-3">
210 <div className="issue-detail">
142211 {notice ? (
143 <p role="status" className="rounded-md bg-shell-active px-3 py-1.5 text-[12.5px]">
212 <p role="status" className="issue-detail-notice">
144213 {notice}
145214 </p>
146215 ) : null}
147216
148 <div className="issue-detail-actions flex flex-wrap items-center gap-2">
149 <button
150 type="button"
151 onClick={onBack}
152 className="flex items-center gap-1 rounded-md px-2 py-1 text-[13px] transition-colors hover:bg-shell-active"
153 >
154 <ArrowLeft className="h-4 w-4" aria-hidden /> {t('issue.board.backToBoard')}
217 <div className="issue-detail-topline">
218 <button type="button" onClick={onBack} className="issue-action">
219 <ArrowLeft className="h-3.5 w-3.5" aria-hidden /> {t('issue.board.backToBoard')}
155220 </button>
156 <span className="font-mono text-[13px] text-shell-ink-muted">#{issue.number}</span>
157 <StateBadge state={issue.state} />
158 <PriorityBadge priority={issue.priority} />
159 <div className="ml-auto flex items-center gap-1.5">
221 <div className="issue-detail-actions">
160222 <button
161223 type="button"
162224 onClick={() => onEdit(issue)}
163 className="flex items-center gap-1 rounded-md bg-shell-field px-2.5 py-1 text-[12.5px] transition-colors hover:brightness-95"
225 className="issue-action"
164226 >
165227 <Pencil className="h-3.5 w-3.5" aria-hidden /> {t('issue.board.edit')}
166228 </button>
@@ -168,7 +230,7 @@
168230 <button
169231 type="button"
170232 onClick={() => void run(() => closeIssue(boardRef, issue.id), t('issue.board.closedIssue'))}
171 className="rounded-md bg-shell-action px-2.5 py-1 text-[12.5px] font-semibold text-white transition-[filter] hover:brightness-95"
233 className="issue-action issue-action-strong"
172234 >
173235 {t('issue.board.closeIssue')}
174236 </button>
@@ -176,118 +238,180 @@
176238 <button
177239 type="button"
178240 onClick={() => void run(() => reopenIssue(boardRef, issue.id), t('issue.board.reopenedIssue'))}
179 className="rounded-md bg-shell-field px-2.5 py-1 text-[12.5px] font-semibold transition-colors hover:brightness-95"
241 className="issue-action issue-action-strong"
180242 >
181243 {t('issue.board.reopen')}
182244 </button>
183245 )}
184 <DangerButton label={t('common.action.delete')} onConfirm={() => void deleteAndBack(issue.id)} />
246 <DangerButton label={t('common.action.delete')} className="issue-action-danger" onConfirm={() => void deleteAndBack(issue.id)} />
185247 </div>
186248 </div>
187249
188 <div className="issue-detail-layout flex flex-wrap items-start gap-4">
189 <div className="min-w-0 flex-1 space-y-3">
190 <article className="issue-detail-article rounded-lg border-2 border-shell-hairline bg-shell-surface p-4">
191 <h2 className="text-[17px] font-semibold leading-7 break-words">{issue.title}</h2>
192 {issue.description ? (
193 <p className="mt-2 whitespace-pre-wrap rounded-md bg-shell-field px-3 py-2 text-[13px] leading-6 break-words">
194 {issue.description}
195 </p>
196 ) : null}
197 {issue.content ? (
198 <p className="mt-3 whitespace-pre-wrap text-[13.5px] leading-6 break-words">{issue.content}</p>
199 ) : !issue.description ? (
200 <p className="mt-3 text-[12.5px] text-shell-ink-muted">{t('issue.board.noBody')}</p>
250 <div className="issue-detail-layout">
251 <div className="issue-detail-main">
252 <header className="issue-detail-header">
253 <p className="issue-detail-key">
254 <span className="issue-detail-number">#{issue.number}</span>
255 <StateBadge state={issue.state} />
256 <PriorityBadge priority={issue.priority} />
257 </p>
258 <h1 className="issue-detail-title">{issue.title}</h1>
259 {issue.description ? <p className="issue-detail-desc">{issue.description}</p> : null}
260 {issue.content ? <p className="issue-detail-desc">{issue.content}</p> : null}
261 {!issue.description && !issue.content ? (
262 <p className="issue-detail-desc is-empty">{t('issue.board.noBody')}</p>
201263 ) : null}
202 <p className="mt-4 flex flex-wrap items-center gap-2 border-t-2 border-shell-hairline pt-3 text-[12px] text-shell-ink-muted">
264 <p className="issue-detail-byline">
203265 {issue.author ? <UserAvatar user={issue.author} size={20} /> : null}
204 <span>{t('issue.board.createdAt', { author: issue.author?.display_name ?? t('issue.board.unknownAuthor'), time: fmtTime(issue.created_at) })}</span>
266 <span>
267 {t('issue.board.createdAt', {
268 author: issue.author?.display_name ?? t('issue.board.unknownAuthor'),
269 time: fmtTime(issue.created_at),
270 })}
271 </span>
205272 {issue.updated_at && issue.updated_at !== issue.created_at ? (
206273 <span>· {t('issue.board.updatedAt', { time: fmtTime(issue.updated_at) })}</span>
207274 ) : null}
208275 {issue.closed_at ? <span>· {t('issue.board.closedAt', { time: fmtTime(issue.closed_at) })}</span> : null}
209276 </p>
210 </article>
211
212 <CommentThread
213 boardRef={boardRef}
214 issueId={issue.id}
215 nodes={comments.data ?? []}
216 loading={comments.loading && !comments.data}
217 failed={Boolean(comments.error)}
218 viewerId={viewerId}
219 onReload={() => setRetry(v => v + 1)}
220 />
277 </header>
278
279 <section className="issue-feed">
280 <div className="issue-feed-head">
281 <h2>{t('issue.board.activity')}</h2>
282 <span className="issue-feed-count">{tc('issue.board.activityCount', events.data?.total ?? 0)}</span>
283 <div className="issue-feed-head-actions">
284 <FilterMenu
285 bare
286 align="right"
287 label={t('issue.board.notifyField')}
288 leading={
289 <>
290 <Bell className="h-3.5 w-3.5" aria-hidden />
291 <span>{t('issue.board.notifyField')}</span>
292 </>
293 }
294 value={issue.viewer_watch ?? 'unwatched'}
295 options={(Object.keys(WATCH_LABEL_KEYS) as WatchMode[]).map(mode => ({
296 value: mode,
297 label: t(WATCH_LABEL_KEYS[mode]),
298 }))}
299 onChange={value => void run(() => setIssueWatch(boardRef, issue.id, value as WatchMode))}
300 />
301 </div>
302 </div>
221303
222 <section className="rounded-lg border-2 border-shell-hairline bg-shell-surface">
223 <button
224 type="button"
225 onClick={() => setShowActivity(v => !v)}
226 className="flex w-full items-center gap-2 px-4 py-2.5 text-[13px] font-semibold"
227 aria-expanded={showActivity}
228 >
229 <Link2 className="h-3.5 w-3.5 text-shell-ink-muted" aria-hidden />
230 {t('issue.board.activity')}
231 <span className="text-[12px] font-normal text-shell-ink-muted">{tc('issue.board.activityCount', events.data?.total ?? 0)}</span>
232 <span className="ml-auto text-[12px] font-normal text-shell-ink-muted">{showActivity ? t('issue.board.collapse') : t('issue.board.expand')}</span>
233 </button>
234 {showActivity ? (
235 <ActivityList events={events.data?.items ?? []} loading={events.loading && !events.data} failed={Boolean(events.error)} />
236 ) : null}
237 </section>
238 </div>
304 {comments.loading && !comments.data ? <p className="issue-feed-note">{t('issue.board.loadingComments')}</p> : null}
305 {comments.error ? <p className="issue-feed-note">{t('issue.board.commentsFailed')}</p> : null}
306 {events.error ? <p className="issue-feed-note">{t('issue.board.activityFailed')}</p> : null}
239307
240 <aside className="issue-detail-sidebar w-[240px] shrink-0 space-y-3.5">
241 <Field label={t('issue.board.filter.priority')}>
242 <FilterMenu
243 label={t('issue.board.filter.priority')}
244 value={issue.priority}
245 options={PRIORITIES.map(meta => ({ value: meta.id, label: meta.label, color: meta.color }))}
246 onChange={value => void run(() => updateIssue(boardRef, issue.id, { priority: value as never }))}
247 />
248 </Field>
249
250 <Field label={t('issue.board.filter.milestone')}>
251 <FilterMenu
252 label={t('issue.board.filter.milestone')}
253 placeholder={t('issue.board.unset')}
254 value={issue.milestone_id ?? ''}
255 options={milestoneOptions.map(m => ({ value: m.id, label: m.name }))}
256 onChange={value => void run(() => updateIssue(boardRef, issue.id, { milestone_id: value || null }))}
257 />
258 </Field>
259
260 <Field label={t('issue.board.filter.due')}>
261 <input
262 type="date"
263 value={dateInputValue(issue.due_at)}
264 onChange={event =>
265 void run(() => updateIssue(boardRef, issue.id, { due_at: dateApiValue(event.target.value) }))
266 }
267 className="h-7 w-full rounded-md border-2 border-shell-hairline bg-shell-field px-2 text-[12.5px] outline-none focus-visible:border-shell-ink"
268 />
269 {issue.due_at ? (
308 {earlier > 0 ? (
270309 <button
271310 type="button"
272 onClick={() => void run(() => updateIssue(boardRef, issue.id, { due_at: null }))}
273 className="mt-1 text-[11.5px] text-shell-ink-muted hover:text-shell-ink"
311 className="issue-feed-more"
312 aria-expanded={feedExpanded}
313 onClick={() => setFeedExpanded(v => !v)}
274314 >
275 {t('issue.board.clearDate')}
315 <ChevronDown
316 className={`h-3.5 w-3.5 transition-transform ${feedExpanded ? 'rotate-180' : ''}`}
317 aria-hidden
318 />
319 {feedExpanded ? t('issue.board.hideEarlier') : t('issue.board.showEarlier', { count: earlier })}
276320 </button>
277321 ) : null}
278 </Field>
279
280 <Field label={t('issue.board.filter.assignee')}>
281 <AssigneePicker
282 members={data.members.data?.items ?? []}
283 selected={issue.assignees.map(user => user.user_id)}
284 busy={false}
285 onChange={ids => void run(() => updateIssue(boardRef, issue.id, { assignee_ids: ids }))}
286 />
287 </Field>
288322
289 <Field label={t('issue.board.labelsField')}>
290 <div className="flex flex-wrap gap-1">
323 {visibleFeed.length === 0 && !comments.loading && !events.loading ? (
324 <p className="issue-feed-note">{t('issue.board.noActivity')}</p>
325 ) : null}
326
327 {visibleFeed.map(entry =>
328 entry.kind === 'comment' ? (
329 <CommentCard
330 key={entry.key}
331 node={entry.node}
332 boardRef={boardRef}
333 issueId={issue.id}
334 viewerId={viewerId}
335 onReload={() => setRetry(v => v + 1)}
336 />
337 ) : (
338 <EventRow key={entry.key} event={entry.event} />
339 ),
340 )}
341
342 <CommentComposer boardRef={boardRef} issueId={issue.id} onReload={() => setRetry(v => v + 1)} />
343 </section>
344 </div>
345
346 <aside className="issue-detail-rail">
347 <div className="issue-rail-group">
348 <p className="issue-rail-title">{t('issue.board.properties')}</p>
349 <div className="issue-rail-rows">
350 <FilterMenu
351 bare
352 label={t('issue.board.filter.priority')}
353 leading={
354 <>
355 <Flag className="h-3.5 w-3.5" aria-hidden />
356 <span>{t('issue.board.filter.priority')}</span>
357 </>
358 }
359 value={issue.priority}
360 options={PRIORITIES.map(meta => ({ value: meta.id, label: meta.label, color: meta.color }))}
361 onChange={value => void run(() => updateIssue(boardRef, issue.id, { priority: value as never }))}
362 />
363 <FilterMenu
364 bare
365 label={t('issue.board.filter.milestone')}
366 leading={
367 <>
368 <MilestoneIcon className="h-3.5 w-3.5" aria-hidden />
369 <span>{t('issue.board.filter.milestone')}</span>
370 </>
371 }
372 value={issue.milestone_id ?? ''}
373 active={milestoneName || undefined}
374 placeholder={t('issue.board.unset')}
375 options={milestoneOptions.map(m => ({ value: m.id, label: m.name }))}
376 onChange={value => void run(() => updateIssue(boardRef, issue.id, { milestone_id: value || null }))}
377 />
378 <div className="issue-prop-row">
379 <Calendar className="issue-prop-icon h-3.5 w-3.5" aria-hidden />
380 <span className="issue-prop-label">{t('issue.board.filter.due')}</span>
381 <span className="issue-prop-value">
382 {issue.due_at ? (
383 <button
384 type="button"
385 className="issue-prop-clear"
386 aria-label={t('issue.board.clearDate')}
387 title={t('issue.board.clearDate')}
388 onClick={() => void run(() => updateIssue(boardRef, issue.id, { due_at: null }))}
389 >
390 <X className="h-3 w-3" aria-hidden />
391 </button>
392 ) : null}
393 <input
394 type="date"
395 aria-label={t('issue.board.filter.due')}
396 value={dateInputValue(issue.due_at)}
397 onChange={event =>
398 void run(() => updateIssue(boardRef, issue.id, { due_at: dateApiValue(event.target.value) }))
399 }
400 className={`issue-prop-date bg-transparent ${issue.due_at ? '' : 'is-empty'}`}
401 />
402 </span>
403 </div>
404 <AssigneeMenu
405 members={data.members.data?.items ?? []}
406 selected={issue.assignees.map(user => user.user_id)}
407 onChange={ids => void run(() => updateIssue(boardRef, issue.id, { assignee_ids: ids }))}
408 />
409 </div>
410 </div>
411
412 <div className="issue-rail-group">
413 <p className="issue-rail-title">{t('issue.board.labelsField')}</p>
414 <div className="issue-rail-labels">
291415 {boundLabels.map(label => (
292416 <LabelChip
293417 key={label.id}
@@ -296,41 +420,24 @@
296420 />
297421 ))}
298422 {boundLabels.length === 0 ? (
299 <span className="text-[12px] text-shell-ink-muted">
423 <span className="issue-rail-empty">
300424 {labels.error ? t('issue.board.labelsFailed') : t('issue.board.noLabels')}
301425 </span>
302426 ) : null}
427 <LabelBinder
428 labels={data.labels.data?.items ?? []}
429 bound={boundLabels.map(label => label.id)}
430 onToggle={(labelId, on) =>
431 void run(() => (on ? bindLabel(boardRef, issue.id, labelId) : unbindLabel(boardRef, issue.id, labelId)))
432 }
433 />
303434 </div>
304 <LabelBinder
305 labels={data.labels.data?.items ?? []}
306 bound={boundLabels.map(label => label.id)}
307 onToggle={(labelId, on) =>
308 void run(() => (on ? bindLabel(boardRef, issue.id, labelId) : unbindLabel(boardRef, issue.id, labelId)))
309 }
310 />
311 </Field>
435 </div>
312436
313 <Field label={t('issue.board.linkedPulls')}>
437 <div className="issue-rail-group">
438 <p className="issue-rail-title">{t('issue.board.linkedPulls')}</p>
314439 <IssuePullLinks boardRef={boardRef} issueId={issue.id} onChanged={() => void onChanged()} />
315 </Field>
316
317 <Field label={t('issue.board.notifyField')}>
318 <FilterMenu
319 label={t('issue.board.notifyField')}
320 value={issue.viewer_watch ?? 'unwatched'}
321 options={(Object.keys(WATCH_LABEL_KEYS) as WatchMode[]).map(mode => ({
322 value: mode,
323 label: t(WATCH_LABEL_KEYS[mode]),
324 }))}
325 onChange={value => void run(() => setIssueWatch(boardRef, issue.id, value as WatchMode))}
326 />
327 </Field>
328
329 {issue.milestone_id ? (
330 <p className="text-[11.5px] text-shell-ink-muted">
331 {t('issue.board.milestoneLabel', { name: milestoneOptions.find(m => m.id === issue.milestone_id)?.name ?? t('issue.board.milestoneUnknown') })}
332 </p>
333 ) : null}
440 </div>
334441 </aside>
335442 </div>
336443 </div>
@@ -397,43 +504,50 @@
397504 }
398505
399506 return (
400 // `w-full` keeps this panel on its own line inside `Field`'s flex-wrap
401 // body: as a bare flex item its automatic minimum size is min-content, so
402 // the repo/PR inputs pushed the row past the 240px sidebar column.
403 <div className="w-full min-w-0 space-y-1.5">
507 <div className="issue-pull-links">
404508 {rows.map((link: IssuePullLink) => (
405 <p key={link.relation_id} className="flex items-start gap-1 text-[12.5px]">
406 <Link2 className="mt-1 inline h-3 w-3 shrink-0 text-shell-ink-muted" aria-hidden />
509 <p key={link.relation_id} className="issue-pull-row">
510 <GitPullRequest className="issue-prop-icon h-3.5 w-3.5" aria-hidden />
407511 <span className="min-w-0 flex-1 truncate" title={link.pull.title}>
408512 #{link.pull.number} {link.pull.title}
409 <span className="ml-1 text-shell-ink-muted">{t('issue.board.pullState', { state: link.pull.state === 'open' ? t('issue.board.stateOpen') : link.pull.state === 'merged' ? t('pull.state.merged') : t('pull.state.closed') })}</span>
513 <span className="issue-pull-state">
514 {t('issue.board.pullState', {
515 state:
516 link.pull.state === 'open'
517 ? t('issue.board.stateOpen')
518 : link.pull.state === 'merged'
519 ? t('pull.state.merged')
520 : t('pull.state.closed'),
521 })}
522 </span>
410523 </span>
411524 <button
412525 type="button"
413526 aria-label={t('issue.board.unlink')}
414527 title={t('issue.board.unlink')}
415 className="shrink-0 text-shell-ink-muted hover:text-[#b42318]"
528 className="issue-prop-clear"
416529 onClick={() =>
417530 void unlinkIssuePull(boardRef.space, boardRef.board, issueId, link.relation_id)
418531 .then(() => setTick(t => t + 1))
419532 .catch(() => undefined)
420533 }
421534 >
422 ×
535 <X className="h-3 w-3" aria-hidden />
423536 </button>
424537 </p>
425538 ))}
426 {rows.length === 0 ? <p className="text-[12px] text-shell-ink-muted">{t('issue.board.noPullLinks')}</p> : null}
539 {rows.length === 0 ? <p className="issue-rail-empty">{t('issue.board.noPullLinks')}</p> : null}
427540 {/*
428541 A repo is addressed by its exact name, so it is picked from the space's
429 own repos instead of typed. The menu sits on its own line: it is a
430 `shrink-0` item sized to 100% of the sidebar, which cannot share a row
431 with the PR field inside this 240px column.
542 own repos instead of typed; the PR is then picked from that repo's own
543 numbering. Both pickers read as plain rail rows.
432544 */}
433545 {repoOptions.length > 0 ? (
434546 <FilterMenu
547 bare
435548 label={t('issue.board.repoPlaceholder')}
436549 placeholder={t('issue.board.repoPlaceholder')}
550 leading={<FolderGit2 className="h-3.5 w-3.5" aria-hidden />}
437551 value={repoName}
438552 options={repoOptions}
439553 onChange={next => {
@@ -443,20 +557,22 @@
443557 }}
444558 />
445559 ) : (
446 <span className="block h-7 truncate text-[12px] leading-7 text-shell-ink-muted">
560 <span className="issue-rail-empty">
447561 {repos.loading ? t('ui.loading') : t('issue.board.noRepos')}
448562 </span>
449563 )}
450564 {pullOptions.length > 0 ? (
451565 <FilterMenu
566 bare
452567 label={t('issue.board.pullPlaceholder')}
453568 placeholder={t('issue.board.pickPull')}
569 leading={<GitPullRequest className="h-3.5 w-3.5" aria-hidden />}
454570 value={pullRef}
455571 options={pullOptions}
456572 onChange={setPullRef}
457573 />
458574 ) : (
459 <span className="block h-7 truncate text-[12px] leading-7 text-shell-ink-muted">
575 <span className="issue-rail-empty">
460576 {!repoName
461577 ? t('issue.board.pickRepoFirst')
462578 : openPulls.loading
@@ -464,65 +580,105 @@
464580 : t('issue.board.noOpenPulls')}
465581 </span>
466582 )}
467 <button type="button" disabled={busy || !repoName || !pullRef} onClick={() => void link()} className="h-7 w-full shrink-0 rounded-md bg-shell-active px-2 text-[12px] font-medium disabled:opacity-50">
583 <button type="button" disabled={busy || !repoName || !pullRef} onClick={() => void link()} className="issue-link-button">
468584 {t('issue.board.link')}
469585 </button>
470 {error ? <p className="text-[11.5px] text-[#b42318]">{error}</p> : null}
586 {error ? <p className="issue-rail-error">{error}</p> : null}
471587 </div>
472588 )
473589}
474590
475function Field({ label, children }: { label: string; children: React.ReactNode }) {
476 return (
477 <div className="space-y-1.5">
478 <p className="issue-field-label text-[12px] font-medium text-shell-ink-muted">{label}</p>
479 <div className="flex flex-wrap items-center gap-1.5">{children}</div>
480 </div>
481 )
482}
483
484function AssigneePicker({
591/**
592 * 负责人:多选。收起时是一行属性(头像 + 名字),展开后是成员勾选清单。
593 */
594function AssigneeMenu({
485595 members,
486596 selected,
487 busy,
488597 onChange,
489598}: {
490599 members: { user?: import('@/entities/issue').UserDto | null }[]
491600 selected: string[]
492 busy: boolean
493601 onChange: (ids: string[]) => void
494602}) {
495 if (members.length === 0) {
496 return <span className="text-[12px] text-shell-ink-muted">{tr('issue.board.noMembers')}</span>
497 }
603 const { t } = useT()
604 const [open, setOpen] = useState(false)
605 const box = useRef<HTMLDivElement | null>(null)
606
607 useEffect(() => {
608 if (!open) return
609 const onDown = (event: MouseEvent) => {
610 if (box.current && !box.current.contains(event.target as Node)) setOpen(false)
611 }
612 const onKey = (event: KeyboardEvent) => {
613 if (event.key === 'Escape') setOpen(false)
614 }
615 window.addEventListener('mousedown', onDown)
616 window.addEventListener('keydown', onKey)
617 return () => {
618 window.removeEventListener('mousedown', onDown)
619 window.removeEventListener('keydown', onKey)
620 }
621 }, [open])
622
623 const users = members.map(member => member.user).filter((user): user is UserDto => Boolean(user))
624 const picked = users.filter(user => selected.includes(user.user_id))
625
498626 return (
499 <div className="w-full space-y-1">
500 {members.map(member => {
501 const user = member.user
502 if (!user) return null
503 const on = selected.includes(user.user_id)
504 return (
505 <label
506 key={user.user_id}
507 className={`flex w-full items-center gap-2 rounded px-1.5 py-1 text-[12.5px] transition-colors ${
508 on ? 'bg-shell-active' : 'hover:bg-shell-field'
509 } ${busy ? 'opacity-60' : 'cursor-pointer'}`}
510 >
511 <input
512 type="checkbox"
513 checked={on}
514 disabled={busy}
515 onChange={() =>
516 onChange(on ? selected.filter(id => id !== user.user_id) : [...selected, user.user_id])
517 }
518 className="h-3.5 w-3.5 accent-[#2f3438]"
519 aria-label={tr('issue.board.assignAria', { name: user.display_name || user.username })}
520 />
521 <UserAvatar user={user} size={18} />
522 <span className="min-w-0 flex-1 truncate">{user.display_name || user.username}</span>
523 </label>
524 )
525 })}
627 <div ref={box} className="relative">
628 <button
629 type="button"
630 aria-haspopup="listbox"
631 aria-expanded={open}
632 onClick={() => setOpen(v => !v)}
633 className="issue-prop-row w-full"
634 >
635 <Users className="issue-prop-icon h-3.5 w-3.5" aria-hidden />
636 <span className="issue-prop-label">{t('issue.board.assigneeField')}</span>
637 <span className="issue-prop-value">
638 {picked.length > 0 ? (
639 <>
640 <AvatarStack users={picked} size={18} />
641 {picked.length === 1 ? (
642 <span className="issue-prop-text">{picked[0].display_name || picked[0].username}</span>
643 ) : null}
644 </>
645 ) : (
646 <span className="issue-prop-text is-empty">{t('issue.board.unassigned')}</span>
647 )}
648 <ChevronDown className="issue-prop-chevron h-3 w-3" aria-hidden />
649 </span>
650 </button>
651 {open ? (
652 <div className="board-filter-popover issue-prop-popover" role="listbox" aria-multiselectable="true" aria-label={t('issue.board.assigneeField')}>
653 {users.length === 0 ? (
654 <p className="issue-rail-empty">{t('issue.board.noMembers')}</p>
655 ) : (
656 users.map(user => {
657 const on = selected.includes(user.user_id)
658 return (
659 <button
660 key={user.user_id}
661 type="button"
662 role="option"
663 aria-selected={on}
664 onClick={() =>
665 onChange(on ? selected.filter(id => id !== user.user_id) : [...selected, user.user_id])
666 }
667 className="board-filter-option flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[12.5px] transition-colors hover:bg-shell-field"
668 >
669 <span className={`issue-check ${on ? 'is-on' : ''}`} aria-hidden>
670 {on ? <Check className="h-3 w-3" /> : null}
671 </span>
672 <UserAvatar user={user} size={18} />
673 <span className={`min-w-0 flex-1 truncate ${on ? 'font-medium' : ''}`}>
674 {user.display_name || user.username}
675 </span>
676 </button>
677 )
678 })
679 )}
680 </div>
681 ) : null}
526682 </div>
527683 )
528684}
@@ -537,55 +693,59 @@
537693 onToggle: (labelId: string, on: boolean) => void
538694}) {
539695 const [open, setOpen] = useState(false)
696 const box = useRef<HTMLDivElement | null>(null)
697 useEffect(() => {
698 if (!open) return
699 const onDown = (event: MouseEvent) => {
700 if (box.current && !box.current.contains(event.target as Node)) setOpen(false)
701 }
702 const onKey = (event: KeyboardEvent) => {
703 if (event.key === 'Escape') setOpen(false)
704 }
705 window.addEventListener('mousedown', onDown)
706 window.addEventListener('keydown', onKey)
707 return () => {
708 window.removeEventListener('mousedown', onDown)
709 window.removeEventListener('keydown', onKey)
710 }
711 }, [open])
540712 if (labels.length === 0) return null
541713 const missing = labels.filter(label => !bound.includes(label.id))
542714 return (
543 <div className="w-full">
715 <div ref={box} className="relative">
544716 <button
545717 type="button"
718 aria-expanded={open}
546719 onClick={() => setOpen(v => !v)}
547 className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[12px] text-shell-ink-muted transition-colors hover:bg-shell-field hover:text-shell-ink"
720 className="issue-prop-add"
548721 >
549 <Tag className="h-3 w-3" aria-hidden /> {open ? tr('issue.board.done') : tr('issue.board.addLabel')}
722 <Plus className="h-3.5 w-3.5" aria-hidden /> {tr('issue.board.addLabel')}
550723 </button>
551 {open && missing.length > 0 ? (
552 <div className="mt-1 max-h-[180px] space-y-0.5 overflow-y-auto rounded-md border-2 border-shell-hairline bg-shell-surface p-1">
724 {open ? (
725 <div className="board-filter-popover issue-prop-popover" role="listbox" aria-label={tr('issue.board.addLabel')}>
553726 {missing.map(label => (
554 <button
555 key={label.id}
556 type="button"
557 onClick={() => onToggle(label.id, true)}
558 className="flex w-full items-center gap-2 rounded px-1.5 py-1 text-left text-[12.5px] transition-colors hover:bg-shell-field"
559 >
727 <button key={label.id} type="button" role="option" aria-selected={false} onClick={() => onToggle(label.id, true)} className="board-filter-option flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[12.5px] transition-colors hover:bg-shell-field">
560728 <span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: normalizeColor(label.color) }} aria-hidden />
561729 <span className="min-w-0 flex-1 truncate">{label.name}</span>
562730 </button>
563731 ))}
732 {missing.length === 0 ? <p className="issue-rail-empty">{tr('issue.board.allLabelsBound')}</p> : null}
564733 </div>
565734 ) : null}
566 {open && missing.length === 0 ? (
567 <p className="mt-1 text-[11.5px] text-shell-ink-muted">{tr('issue.board.allLabelsBound')}</p>
568 ) : null}
569735 </div>
570736 )
571737}
572738
573/* -------------------------------------------------------------- comments */
739/* ------------------------------------------- activity feed and comments */
574740
575function CommentThread({boardRef,
741/** Bottom composer: a quiet card that reads like the comment it will add. */
742function CommentComposer({
743 boardRef,
576744 issueId,
577 nodes,
578 loading,
579 failed,
580 viewerId,
581745 onReload,
582746}: {
583747 boardRef: BoardRef
584748 issueId: string
585 nodes: import('@/entities/issue').CommentNode[]
586 loading: boolean
587 failed: boolean
588 viewerId?: string
589749 onReload: () => void
590750}) {
591751 const [draft, setDraft] = useState('')
@@ -609,53 +769,41 @@
609769 }
610770
611771 return (
612 <section className="space-y-3 rounded-lg border-2 border-shell-hairline bg-shell-surface p-4">
613 <h3 className="text-[13px] font-semibold">{tr('issue.board.comments')}</h3>
614 {loading ? <PanelNote text={tr('issue.board.loadingComments')} /> : null}
615 {failed && !loading ? <PanelNote text={tr('issue.board.commentsFailed')} /> : null}
616 {nodes.map(node => (
617 <CommentNodeView
618 key={node.comment.id}
619 node={node}
620 boardRef={boardRef}
621 issueId={issueId}
622 viewerId={viewerId}
623 onReload={onReload}
624 />
625 ))}
626 {nodes.length === 0 && !loading && !failed ? (
627 <p className="text-[12.5px] text-shell-ink-muted">{tr('issue.board.noComments')}</p>
772 <div className="comment-card issue-composer">
773 <textarea
774 value={draft}
775 onChange={event => setDraft(event.target.value)}
776 onKeyDown={event => {
777 if ((event.metaKey || event.ctrlKey) && event.key === 'Enter' && !event.nativeEvent.isComposing) void submit()
778 }}
779 rows={2}
780 placeholder={tr('issue.board.commentPlaceholder')}
781 aria-label={tr('issue.board.newCommentAria')}
782 className="comment-input bg-transparent"
783 />
784 {error ? (
785 <p role="alert" className="comment-error">
786 {error}
787 </p>
628788 ) : null}
629 {error ? <p role="alert" className="text-[12px] text-[#b42318]">{error}</p> : null}
630 <div className="space-y-2">
631 <textarea
632 value={draft}
633 onChange={event => setDraft(event.target.value)}
634 onKeyDown={event => {
635 if ((event.metaKey || event.ctrlKey) && event.key === 'Enter' && !event.nativeEvent.isComposing) void submit()
636 }}
637 rows={3}
638 placeholder={tr('issue.board.commentPlaceholder')}
639 aria-label={tr('issue.board.newCommentAria')}
640 className="w-full resize-y rounded-md border-2 border-shell-hairline bg-shell-field p-2.5 text-[13px] leading-5 outline-none focus-visible:border-shell-ink"
641 />
642 <div className="flex items-center justify-end gap-2">
643 {busy ? <Loader2 className="h-3.5 w-3.5 animate-spin text-shell-ink-muted" aria-hidden /> : null}
644 <button
645 type="button"
646 disabled={busy || draft.trim().length === 0}
647 onClick={() => void submit()}
648 className="rounded-md bg-shell-action px-3 py-1.5 text-[12.5px] font-semibold text-white transition-[filter] hover:brightness-95 disabled:opacity-50"
649 >
650 {tr('issue.board.submitComment')}
651 </button>
652 </div>
789 <div className="comment-foot">
790 {busy ? <Loader2 className="h-3.5 w-3.5 animate-spin text-shell-ink-muted" aria-hidden /> : null}
791 <button
792 type="button"
793 disabled={busy || draft.trim().length === 0}
794 onClick={() => void submit()}
795 aria-label={tr('issue.board.submitComment')}
796 title={tr('issue.board.submitComment')}
797 className="comment-send"
798 >
799 <ArrowUp className="h-4 w-4" aria-hidden />
800 </button>
653801 </div>
654 </section>
802 </div>
655803 )
656804}
657805
658function CommentNodeView({
806function CommentCard({
659807 node,
660808 boardRef,
661809 issueId,
@@ -690,21 +838,37 @@
690838 }
691839 }
692840
841 async function submitReply() {
842 const content = reply.trim()
843 if (!content || busy) return
844 setBusy(true)
845 setError(null)
846 try {
847 await createComment(boardRef, issueId, content, comment.id)
848 setReply('')
849 onReload()
850 } catch (err) {
851 setError((err as Error)?.message ?? tr('issue.board.replyFailed'))
852 } finally {
853 setBusy(false)
854 }
855 }
856
693857 return (
694 <div className="rounded-md bg-shell-board p-3">
695 <div className="flex items-center gap-2">
696 {comment.poster ? <UserAvatar user={comment.poster} size={20} /> : null}
697 <span className="text-[12.5px] font-semibold">{comment.poster?.display_name ?? tr('common.state.unknownUser')}</span>
698 <span className="text-[11.5px] text-shell-ink-muted">{fmtTime(comment.created_at)}</span>
858 <article className="comment-card">
859 <div className="comment-head">
860 {comment.poster ? <UserAvatar user={comment.poster} size={22} /> : null}
861 <span className="comment-author">{comment.poster?.display_name ?? tr('common.state.unknownUser')}</span>
862 <span className="comment-time">{fmtTime(comment.created_at)}</span>
699863 {comment.updated_at && comment.updated_at !== comment.created_at ? (
700 <span className="text-[11px] text-shell-ink-muted">{tr('issue.board.edited')}</span>
864 <span className="comment-time">{tr('issue.board.edited')}</span>
701865 ) : null}
702 <span className="ml-auto flex items-center gap-1">
866 <span className="comment-actions">
703867 {mine ? (
704868 <button
705869 type="button"
706870 onClick={() => setEditing(v => !v)}
707 className="rounded px-1.5 py-0.5 text-[11.5px] text-shell-ink-muted transition-colors hover:bg-shell-active hover:text-shell-ink"
871 className="comment-action"
708872 >
709873 {editing ? tr('common.action.cancel') : tr('common.action.edit')}
710874 </button>
@@ -719,33 +883,37 @@
719883 </span>
720884 </div>
721885 {editing ? (
722 <div className="mt-2 space-y-2">
886 <div className="comment-editor">
723887 <textarea
724888 value={text}
725889 onChange={event => setText(event.target.value)}
726890 rows={3}
727891 aria-label={tr('issue.board.editCommentAria')}
728 className="w-full resize-y rounded-md border-2 border-shell-hairline bg-shell-surface p-2 text-[13px] outline-none focus-visible:border-shell-ink"
892 className="comment-input bg-transparent"
729893 />
730 <div className="flex justify-end gap-2">
894 <div className="flex justify-end">
731895 <button
732896 type="button"
733897 disabled={busy || text.trim().length === 0}
734898 onClick={() => void act(() => updateComment(boardRef, issueId, comment.id, text.trim()))}
735 className="rounded-md bg-shell-action px-2.5 py-1 text-[12.5px] font-semibold text-white disabled:opacity-50"
899 className="issue-action issue-action-strong"
736900 >
737901 {tr('common.action.save')}
738902 </button>
739903 </div>
740904 </div>
741905 ) : (
742 <p className="mt-1.5 whitespace-pre-wrap text-[13px] leading-6 break-words">{comment.content}</p>
906 <p className="comment-body">{comment.content}</p>
743907 )}
744 {error ? <p role="alert" className="mt-1 text-[12px] text-[#b42318]">{error}</p> : null}
908 {error ? (
909 <p role="alert" className="comment-error">
910 {error}
911 </p>
912 ) : null}
745913 {node.replies?.length ? (
746 <div className="mt-2 space-y-2 border-l-2 border-shell-hairline pl-3">
914 <div className="comment-replies">
747915 {node.replies.map(reply2 => (
748 <CommentNodeView
916 <CommentCard
749917 key={reply2.comment.id}
750918 node={reply2}
751919 boardRef={boardRef}
@@ -757,59 +925,55 @@
757925 </div>
758926 ) : null}
759927 {!editing ? (
760 <div className="mt-2 flex items-center gap-2">
928 <div className="comment-reply-row">
761929 <input
762930 value={reply}
763931 onChange={event => setReply(event.target.value)}
764 onKeyDown={async event => {
765 if (event.key !== 'Enter' || event.nativeEvent.isComposing || busy || !reply.trim()) return
932 onKeyDown={event => {
933 if (event.key !== 'Enter' || event.nativeEvent.isComposing) return
766934 event.preventDefault()
767 setBusy(true)
768 try {
769 await createComment(boardRef, issueId, reply.trim(), comment.id)
770 setReply('')
771 onReload()
772 } catch (err) {
773 setError((err as Error)?.message ?? tr('issue.board.replyFailed'))
774 } finally {
775 setBusy(false)
776 }
935 void submitReply()
777936 }}
778937 placeholder={tr('issue.board.replyPlaceholder')}
779938 aria-label={tr('issue.board.replyAria', { name: comment.poster?.display_name ?? tr('issue.board.commentFallback') })}
780 className="h-7 min-w-0 flex-1 rounded-md border-2 border-shell-hairline bg-shell-surface px-2 text-[12.5px] outline-none focus-visible:border-shell-ink"
939 className="comment-reply-input bg-transparent"
781940 />
782941 {busy ? <Loader2 className="h-3.5 w-3.5 animate-spin text-shell-ink-muted" aria-hidden /> : null}
942 <button
943 type="button"
944 disabled={busy || reply.trim().length === 0}
945 onClick={() => void submitReply()}
946 aria-label={tr('issue.board.send')}
947 title={tr('issue.board.send')}
948 className="comment-send"
949 >
950 <ArrowUp className="h-3.5 w-3.5" aria-hidden />
951 </button>
783952 </div>
784953 ) : null}
785 </div>
954 </article>
786955 )
787956}
788957
789function ActivityList({
790 events,
791 loading,
792 failed,
793}: {
794 events: import('@/entities/issue').IssueEvent[]
795 loading: boolean
796 failed: boolean
797}) {
798 if (loading) return <PanelNote text={tr('issue.board.loadingActivity')} />
799 if (failed) return <PanelNote text={tr('issue.board.activityFailed')} />
800 if (events.length === 0) return <PanelNote text={tr('issue.board.noActivity')} />
958/** One change event on the timeline: small avatar, verb, timestamp. */
959function EventRow({ event }: { event: import('@/entities/issue').IssueEvent }) {
801960 return (
802 <ul className="divide-y divide-shell-hairline border-t-2 border-shell-hairline">
803 {events.map(event => (
804 <li key={event.id} className="flex items-center gap-2 px-4 py-2 text-[12.5px]">
805 {event.user ? <UserAvatar user={event.user} size={18} /> : <Users className="h-3.5 w-3.5 text-shell-ink-muted" aria-hidden />}
806 <span className="min-w-0 flex-1 truncate">
807 <span className="font-medium">{event.user?.display_name ?? tr('issue.board.system')}</span> {eventLabel(event.event_type)}
808 {event.new_title ? <span className="text-shell-ink-muted"> → {event.new_title}</span> : null}
961 <div className="issue-event">
962 <span className="issue-event-icon">
963 {event.user ? (
964 <UserAvatar user={event.user} size={20} />
965 ) : (
966 <span className="issue-event-dot">
967 <Users className="h-3 w-3" aria-hidden />
809968 </span>
810 <span className="shrink-0 text-[11.5px] text-shell-ink-muted">{fmtTime(event.created_at)}</span>
811 </li>
812 ))}
813 </ul>
969 )}
970 </span>
971 <p className="issue-event-body">
972 <span className="issue-event-actor">{event.user?.display_name ?? tr('issue.board.system')}</span>{' '}
973 {eventLabel(event.event_type)}
974 {event.new_title ? <span className="issue-event-extra"> → {event.new_title}</span> : null}
975 <span className="issue-event-time"> · {fmtTime(event.created_at)}</span>
976 </p>
977 </div>
814978 )
815979}
Modified web/src/features/issue-board/ui/parts.tsx web/src/features/issue-board/ui/parts.tsx +14 −2 View web/src/features/issue-board/ui/parts.tsx
@@ -141,6 +141,8 @@
141141 active,
142142 placeholder,
143143 align = 'left',
144 bare = false,
145 leading,
144146 extra,
145147}: {
146148 label: string
@@ -151,6 +153,10 @@
151153 active?: string
152154 placeholder?: string
153155 align?: 'left' | 'right'
156 /** Bordered chip for toolbars vs. a full-width hover row for the detail rail. */
157 bare?: boolean
158 /** Icon + label pinned to the start of the trigger (rail rows). */
159 leading?: ReactNode
154160 /** Free-form block under the option list (an inline "new label" row, …). */
155161 extra?: ReactNode
156162}) {
@@ -205,14 +211,19 @@
205211 }}
206212 onClick={() => setOpen(v => !v)}
207213 className={`board-filter-trigger flex h-7 items-center gap-1 rounded-md px-2 text-[12.5px] transition-colors ${
214 bare ? 'board-filter-trigger-bare' : ''
215 } ${
208216 value ? 'bg-shell-active text-shell-ink' : 'bg-shell-field text-shell-ink-muted hover:text-shell-ink'
209217 }`}
210218 >
219 {leading ? <span className="board-filter-leading flex shrink-0 items-center gap-2">{leading}</span> : null}
211220 {current?.color ? (
212221 <span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: current.color }} aria-hidden />
213222 ) : null}
214 <span className="max-w-[120px] truncate">{active ?? current?.label ?? placeholder ?? label}</span>
215 <ChevronDown className="h-3 w-3 shrink-0 opacity-70" aria-hidden />
223 <span className="board-filter-trigger-value max-w-[120px] truncate">
224 {active ?? current?.label ?? placeholder ?? label}
225 </span>
226 <ChevronDown className="board-filter-chevron h-3 w-3 shrink-0 opacity-70" aria-hidden />
216227 </button>
217228 {open ? (
218229 <div
@@ -326,6 +337,7 @@
326337 <button
327338 type="button"
328339 disabled={busy}
340 data-armed={armed ? 'true' : undefined}
329341 onClick={() => (armed ? onConfirm() : setArmed(true))}
330342 className={`shrink-0 rounded px-2 py-1 text-[12px] transition-colors disabled:opacity-50 ${
331343 armed ? 'bg-[#e5484d] font-semibold text-white' : 'text-[#b42318] hover:bg-[#e5484d]/10'
Modified web/src/features/notifications/ui/notify-nav-item.tsx web/src/features/notifications/ui/notify-nav-item.tsx +1 −1 View web/src/features/notifications/ui/notify-nav-item.tsx
@@ -20,7 +20,7 @@
2020 to={NOTIFY_PATH}
2121 title={t('notifications.title')}
2222 aria-current={active ? 'page' : undefined}
23 className={`workspace-nav-row flex h-10 items-center gap-1.5 rounded-md pl-2 pr-2 text-[14px] font-medium ${
23 className={`workspace-nav-row flex h-8 items-center gap-1.5 rounded-md pl-2 pr-2 text-[14px] font-medium ${
2424 active
2525 ? 'bg-shell-active text-shell-ink'
2626 : 'text-shell-ink/60 hover:bg-shell-active/60 hover:text-shell-ink'
Modified web/src/features/repo-browse/ui/code-tab.tsx web/src/features/repo-browse/ui/code-tab.tsx +4 −28 View web/src/features/repo-browse/ui/code-tab.tsx
@@ -1,4 +1,4 @@
1import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
1import { useEffect, useMemo, useRef, useState } from 'react'
22import { ArrowLeft, ArrowUp, ChevronRight, Eye, File as FileIcon, FileCode, Folder, FolderGit2, History, Users } from 'lucide-react'
33import { useT } from '@/shared/i18n'
44import { formatBytes, repoKey, useAsync } from '@/shared/lib'
@@ -39,8 +39,6 @@
3939 onOpenCommit: (sha: string) => void
4040 /** Show the commit history for a path (file header links to it). */
4141 onShowHistory: (path: string) => void
42 /** Clone address box, rendered in the header row above the listing. */
43 cloneSlot?: ReactNode
4442}
4543
4644export type FileRef = { path: string; oid?: string }
@@ -61,7 +59,6 @@
6159 closeFile,
6260 onOpenCommit,
6361 onShowHistory,
64 cloneSlot,
6562}: CodeTabProps) {
6663 // Path and file arrive from the route: the URL is the only place this view
6764 // state exists, so a refresh, a back click or a pasted link rebuilds it.
@@ -92,7 +89,6 @@
9289 line={line}
9390 routes={routes}
9491 onBack={closeFile}
95 cloneSlot={cloneSlot}
9692 />
9793 )
9894 }
@@ -104,7 +100,6 @@
104100 path={path}
105101 mode={mode}
106102 routes={routes}
107 cloneSlot={cloneSlot}
108103 />
109104 )
110105}
@@ -118,7 +113,6 @@
118113 path,
119114 mode,
120115 routes,
121 cloneSlot,
122116}: {
123117 space: string
124118 repo: string
@@ -126,22 +120,14 @@
126120 path: string
127121 mode: 'html' | 'json'
128122 routes: FragmentRoutes
129 cloneSlot?: ReactNode
130123}) {
131124 const { t } = useT()
132 const headerless = !cloneSlot
133125 return (
134126 <div className="repo-code-view">
135 <CodeHeader
136 path={path}
137 onNavigate={routes.onNavigate}
138 cloneSlot={cloneSlot}
139 breadcrumbOnly={headerless}
140 />
141127 <div className="repo-tree-card">
142128 <div className="repo-directory-header flex flex-wrap items-center gap-2">
143129 <div className="min-w-0 flex-1">
144 {headerless ? <Breadcrumbs path={path} onNavigate={routes.onNavigate} /> : null}
130 <Breadcrumbs path={path} onNavigate={routes.onNavigate} />
145131 </div>
146132 <button
147133 type="button"
@@ -177,21 +163,14 @@
177163 path,
178164 leaf,
179165 onNavigate,
180 cloneSlot,
181 breadcrumbOnly = false,
182166}: {
183167 path: string
184168 leaf?: string
185169 onNavigate: (p: string) => void
186 cloneSlot?: ReactNode
187 /** Keep the row alive when it carries nothing but the path. */
188 breadcrumbOnly?: boolean
189170}) {
190 if (!cloneSlot && !breadcrumbOnly) return null
191171 return (
192172 <div className="repo-code-header flex flex-wrap items-center gap-2.5">
193173 <Breadcrumbs path={path} leaf={leaf} onNavigate={onNavigate} className="repo-code-header-path" />
194 {cloneSlot ? <div className="repo-code-header-clone flex items-center">{cloneSlot}</div> : null}
195174 </div>
196175 )
197176}
@@ -566,7 +545,7 @@
566545 ref={frameRef}
567546 title={title}
568547 sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox"
569 className={`repo-readme w-full rounded-xl border border-shell-hairline bg-shell-surface ${className}`}
548 className={`repo-readme w-full rounded-xl bg-shell-surface ${className}`}
570549 style={{ height }}
571550 srcDoc={doc}
572551 />
@@ -707,7 +686,7 @@
707686 const result = readme.data
708687 if (!result || readme.loading) return null
709688 return (
710 <div className="repo-readme mt-5 overflow-hidden rounded-xl border border-shell-hairline bg-shell-surface">
689 <div className="repo-readme mt-5 overflow-hidden rounded-xl bg-shell-surface">
711690 <div className="border-b-2 border-shell-hairline px-4 py-2 text-[12.5px] font-semibold">{result.name}</div>
712691 <pre className="max-h-[520px] overflow-auto p-4 font-mono text-[12px] leading-5">{result.text}</pre>
713692 </div>
@@ -725,7 +704,6 @@
725704 line,
726705 routes,
727706 onBack,
728 cloneSlot,
729707}: {
730708 space: string
731709 repo: string
@@ -735,7 +713,6 @@
735713 line?: number
736714 routes: FragmentRoutes
737715 onBack: () => void
738 cloneSlot?: ReactNode
739716}) {
740717 const { t } = useT()
741718 const [blame, setBlame] = useState(false)
@@ -752,7 +729,6 @@
752729 path={dir}
753730 leaf={leaf}
754731 onNavigate={routes.onNavigate}
755 cloneSlot={cloneSlot}
756732 />
757733 <div className="repo-file-toolbar flex flex-wrap items-center gap-2">
758734 <button
Modified web/src/features/space-management/ui/space-settings/images-section.tsx web/src/features/space-management/ui/space-settings/images-section.tsx +73 −69 View web/src/features/space-management/ui/space-settings/images-section.tsx
@@ -3,7 +3,6 @@
33 Alert,
44 Button,
55 SectionTitle,
6 SettingRow,
76 fmtDate,
87 useSave,
98} from '@/shared/ui'
@@ -38,7 +37,7 @@
3837 <Alert tone="error">{t('space.images.loadFailed')}</Alert>
3938 </div>
4039 ) : null}
41 <div className="space-y-2">
40 <div className="divide-y divide-shell-hairline">
4241 {IMAGE_KINDS.map(item => (
4342 <ImageRow
4443 key={item.kind}
@@ -93,79 +92,84 @@
9392 onSpaceUpdated()
9493 }, t('space.images.updated', { label }))
9594
95 const round = kind === 'avatar'
96 const meta = image
97 ? `${image.format} · ${Math.round(image.bytes / 1024)} KB${
98 image.width && image.height ? ` · ${image.width}×${image.height}` : ''
99 } · ${fmtDate(image.updated_at)}`
100 : hint
101
96102 return (
97 <div className="settings-card">
98 <SettingRow
99 lead={
100 image?.url ? (
101 <img
102 src={image.url}
103 alt=''
104 className='h-[46px] w-[76px] shrink-0 rounded-md border border-shell-hairline object-cover'
105 />
106 ) : (
107 <span
108 aria-hidden
109 className='grid h-[46px] w-[76px] shrink-0 place-items-center rounded-md border border-dashed border-shell-hairline bg-shell-field text-[11px] text-shell-ink-muted'
110 >
111 {loading ? t('space.images.loading') : t('space.images.unset')}
112 </span>
113 )
114 }
115 title={label}
116 meta={
117 image
118 ? `${image.format} · ${Math.round(image.bytes / 1024)} KB${
119 image.width && image.height ? ` · ${image.width}×${image.height}` : ''
120 } · ${fmtDate(image.updated_at)}`
121 : hint
122 }
123 actions={
124 <>
125 <input
126 ref={inputRef}
127 type="file"
128 accept="image/png,image/jpeg,image/webp,image/gif"
129 className="hidden"
130 onChange={e => {
131 const file = e.target.files?.[0]
132 if (file) pick(file)
133 e.target.value = ''
134 }}
135 />
103 <div className="grid gap-4 py-5 md:grid-cols-[minmax(0,1fr)_minmax(0,1.05fr)] md:gap-10">
104 <div className="min-w-0">
105 <p className="text-[14px] font-semibold">{label}</p>
106 <p className="mt-1 text-[12px] text-shell-ink-muted">{meta}</p>
107 <div className="mt-3 flex flex-wrap items-center gap-2">
108 <input
109 ref={inputRef}
110 type="file"
111 accept="image/png,image/jpeg,image/webp,image/gif"
112 className="hidden"
113 onChange={e => {
114 const file = e.target.files?.[0]
115 if (file) pick(file)
116 e.target.value = ''
117 }}
118 />
119 <Button
120 variant="primary"
121 className="h-9 px-3.5 text-[13px]"
122 disabled={busy}
123 onClick={() => inputRef.current?.click()}
124 >
125 {image ? t('space.images.replace') : t('common.action.upload')}
126 </Button>
127 {image ? (
136128 <Button
137 variant="secondary"
138 className="h-8 text-[12.5px]"
129 variant="ghost"
130 className="h-9 text-[13px]"
139131 disabled={busy}
140 onClick={() => inputRef.current?.click()}
132 onClick={() =>
133 void run(async () => {
134 await deleteSpaceImage(spaceName, kind)
135 invalidateCache(spaceImageKey(spaceName, kind))
136 onSaved()
137 onSpaceUpdated()
138 }, t('space.images.deleted', { label }))
139 }
141140 >
142 {image ? t('space.images.replace') : t('common.action.upload')}
141 {t('common.action.delete')}
143142 </Button>
144 {image ? (
145 <Button
146 variant="ghost"
147 className="h-8 text-[12.5px]"
148 disabled={busy}
149 onClick={() =>
150 void run(async () => {
151 await deleteSpaceImage(spaceName, kind)
152 invalidateCache(spaceImageKey(spaceName, kind))
153 onSaved()
154 onSpaceUpdated()
155 }, t('space.images.deleted', { label }))
156 }
157 >
158 {t('common.action.delete')}
159 </Button>
160 ) : null}
161 </>
162 }
163 />
164 {notice || error ? (
165 <div className="settings-subpanel">
166 {error ? <Alert tone="error">{error}</Alert> : <Alert tone="success">{notice}</Alert>}
143 ) : null}
167144 </div>
168 ) : null}
145 {notice || error ? (
146 <div className="mt-3">
147 {error ? <Alert tone="error">{error}</Alert> : <Alert tone="success">{notice}</Alert>}
148 </div>
149 ) : null}
150 </div>
151 <div className="min-w-0">
152 {image?.url ? (
153 <img
154 src={image.url}
155 alt=""
156 className={
157 round
158 ? 'h-20 w-20 rounded-full border border-shell-hairline object-cover'
159 : 'max-h-[190px] w-full rounded-xl border border-shell-hairline object-cover'
160 }
161 />
162 ) : (
163 <span
164 aria-hidden
165 className={`grid place-items-center border border-dashed border-shell-hairline bg-shell-field text-[12.5px] text-shell-ink-muted ${
166 round ? 'h-20 w-20 rounded-full' : 'h-[120px] w-full rounded-xl'
167 }`}
168 >
169 {loading ? t('space.images.loading') : t('space.images.unset')}
170 </span>
171 )}
172 </div>
169173 </div>
170174 )
171175}
Deleted web/src/features/space-management/ui/space-settings/invites-section.tsx web/src/features/space-management/ui/space-settings/invites-section.tsx +0 −289
@@ -1,289 +0,0 @@
1import { useState } from 'react'
2import {
3 Alert,
4 Button,
5 Chip,
6 EmptyNote,
7 Field,
8 SectionTitle,
9 SettingRow,
10 fmtDate,
11 inputCls,
12 parseDate,
13 useSave,
14} from '@/shared/ui'
15import { spaceAdminKey, useAsync } from '@/shared/lib'
16import { t as tr, useT } from '@/shared/i18n'
17import {
18 acceptSpaceInvite,
19 createSpaceInvite,
20 findUserByUsername,
21 listSpaceGroups,
22 listSpaceInvites,
23 revokeSpaceInvite,
24} from '../../model/space-admin'
25import type { SpaceGroup, SpaceInvite } from '../../model/space-admin'
26import { userLabel } from './meta'
27import type { SpaceSectionProps } from './props'
28
29type InviteState = 'pending' | 'accepted' | 'revoked' | 'expired'
30
31function inviteState(row: {
32 accepted_at?: string | null
33 revoked_at?: string | null
34 expires_at?: string | null
35}): InviteState {
36 if (row.accepted_at) return 'accepted'
37 if (row.revoked_at) return 'revoked'
38 const expires = parseDate(row.expires_at)
39 if (expires && expires.getTime() < Date.now()) return 'expired'
40 return 'pending'
41}
42
43const INVITE_STATE_KEYS = {
44 pending: 'space.invites.state.pending',
45 accepted: 'space.invites.state.accepted',
46 revoked: 'space.invites.state.revoked',
47 expired: 'space.invites.state.expired',
48} as const
49
50const inviteStateLabel = (state: InviteState) => tr(INVITE_STATE_KEYS[state])
51
52/** Outstanding invitations, and the form that creates them. */
53export function InvitesSection({ spaceName }: SpaceSectionProps) {
54 const { t } = useT()
55 const [tick, setTick] = useState(0)
56 const { busy, notice, error, run } = useSave()
57 const invites = useAsync(() => listSpaceInvites(spaceName), [spaceName, tick], {
58 cacheKey: spaceAdminKey(spaceName, 'invites'),
59 })
60 const groups = useAsync(() => listSpaceGroups(spaceName), [spaceName], {
61 cacheKey: spaceAdminKey(spaceName, 'groups'),
62 })
63 const [mode, setMode] = useState<'email' | 'user'>('email')
64 const [value, setValue] = useState('')
65 const [expires, setExpires] = useState('')
66 const [groupIds, setGroupIds] = useState<Set<string>>(() => new Set())
67 const refresh = () => setTick(t => t + 1)
68
69 const active = (invites.data ?? []).filter(row => inviteState(row) === 'pending')
70 const history = (invites.data ?? []).filter(row => inviteState(row) !== 'pending')
71
72 const send = () =>
73 void run(async () => {
74 const trimmed = value.trim()
75 if (!trimmed) throw new Error(t('space.invites.missingTarget'))
76 // The server rejects an invite that grants no role at all.
77 if (groupIds.size === 0) throw new Error(t('space.invites.missingRole'))
78 const cmd = {
79 ...(mode === 'email' ? { email: trimmed } : {}),
80 ...(mode === 'user' ? { user_id: (await findUserByUsername(trimmed)).user_id } : {}),
81 expires_at: expires ? new Date(expires).toISOString() : null,
82 group_ids: [...groupIds],
83 }
84 await createSpaceInvite(spaceName, cmd)
85 setValue('')
86 setExpires('')
87 setGroupIds(new Set())
88 refresh()
89 }, t('space.invites.created'))
90
91 return (
92 <section>
93 <SectionTitle title={t('space.invites.title')} sub={t('space.invites.sub')} />
94 <div className="settings-card mb-5 p-4">
95 <div className="settings-segment-group mb-4" role="group" aria-label={t('space.invites.mode')}>
96 {(['email', 'user'] as const).map(kind => (
97 <button
98 key={kind}
99 type="button"
100 onClick={() => setMode(kind)}
101 aria-pressed={mode === kind}
102 className="settings-segment"
103 >
104 {kind === 'email' ? t('space.invites.byEmail') : t('space.invites.byUsername')}
105 </button>
106 ))}
107 </div>
108 <div className="grid gap-x-4 sm:grid-cols-2">
109 <Field label={mode === 'email' ? t('space.invites.email') : t('space.invites.username')} hint={t('space.invites.oneOf')}>
110 <input
111 className={`${inputCls} max-w-none`}
112 type={mode === 'email' ? 'email' : 'text'}
113 placeholder={mode === 'email' ? 'ada@example.com' : 'ada'}
114 value={value}
115 onChange={e => setValue(e.target.value)}
116 />
117 </Field>
118 <Field label={t('space.invites.expires')} hint={t('space.invites.expiresHint')}>
119 <input
120 type="datetime-local"
121 className={`${inputCls} max-w-none`}
122 value={expires}
123 onChange={e => setExpires(e.target.value)}
124 />
125 </Field>
126 </div>
127 {(groups.data ?? []).length > 0 ? (
128 <div className="mb-4">
129 <p className="mb-2 font-mono text-[10px] font-bold uppercase tracking-[1.2px] text-shell-ink-muted">
130 {t('space.invites.grantLabel')}
131 </p>
132 <div className="flex flex-wrap gap-2">
133 {groups.data!.map(group => {
134 const on = groupIds.has(group.id)
135 return (
136 <button
137 key={group.id}
138 type="button"
139 aria-pressed={on}
140 className={`settings-chip ${on ? 'is-on' : ''}`}
141 onClick={() =>
142 setGroupIds(prev => {
143 const next = new Set(prev)
144 if (next.has(group.id)) next.delete(group.id)
145 else next.add(group.id)
146 return next
147 })
148 }
149 >
150 {group.unicode_emoji ? `${group.unicode_emoji} ` : ''}
151 {group.name}
152 </button>
153 )
154 })}
155 </div>
156 </div>
157 ) : null}
158 <div className="flex flex-wrap items-center gap-3">
159 <Button className="h-9" disabled={busy || !value.trim()} onClick={send}>
160 {t('space.invites.send')}
161 </Button>
162 {error ? <Alert tone="error">{error}</Alert> : null}
163 {notice && !error ? <Alert tone="success">{notice}</Alert> : null}
164 </div>
165 </div>
166
167 <h3 className="mb-2 text-[14px] font-bold">{t('space.invites.activeTitle')}</h3>
168 {invites.loading ? (
169 <EmptyNote>{t('space.invites.loading')}</EmptyNote>
170 ) : active.length === 0 ? (
171 <EmptyNote>{t('space.invites.nonePending')}</EmptyNote>
172 ) : (
173 <div className="mb-8 space-y-2">
174 {active.map(row => (
175 <InviteRow key={row.id} row={row} spaceName={spaceName} busy={busy} onSaved={refresh} groups={groups.data ?? []} />
176 ))}
177 </div>
178 )}
179 <RedeemInvite busy={busy} />
180 {history.length > 0 ? (
181 <>
182 <h3 className="mb-2 text-[14px] font-bold">{t('space.invites.historyTitle')}</h3>
183 <div className="space-y-2">
184 {history.map(row => (
185 <InviteRow key={row.id} row={row} spaceName={spaceName} busy={busy} onSaved={refresh} groups={groups.data ?? []} />
186 ))}
187 </div>
188 </>
189 ) : null}
190 </section>
191 )
192}
193
194function InviteRow({
195 row,
196 spaceName,
197 busy,
198 onSaved,
199 groups,
200}: {
201 row: SpaceInvite
202 spaceName: string
203 busy: boolean
204 onSaved: () => void
205 groups: SpaceGroup[]
206}) {
207 const { t } = useT()
208 const state = inviteState(row)
209 const label = row.user ? userLabel(row.user) : row.email || t('space.invites.noEmail')
210 const groupNames = row.groups
211 .map(id => groups.find(group => group.id === id)?.name)
212 .filter((name): name is string => Boolean(name))
213
214 return (
215 <SettingRow
216 title={label}
217 meta={
218 <>
219 {inviteStateLabel(state)} · {t('space.roles.createdAt', { date: fmtDate(row.created_at) })}
220 {row.expires_at ? ` · ${t('space.invites.expiresOn', { date: fmtDate(row.expires_at) })}` : ''}
221 {row.accepted_at ? ` · ${t('space.invites.acceptedOn', { date: fmtDate(row.accepted_at) })}` : ''}
222 </>
223 }
224 actions={
225 <>
226 {groupNames.length > 0 ? <Chip>{groupNames.join(', ')}</Chip> : null}
227 {state === 'pending' ? (
228 <Button
229 variant="secondary"
230 className="h-8 text-[12.5px]"
231 disabled={busy}
232 onClick={() =>
233 void revoke(spaceName, row.id, onSaved)
234 }
235 >
236 {t('space.invites.revoke')}
237 </Button>
238 ) : null}
239 </>
240 }
241 />
242 )
243}
244
245async function revoke(spaceName: string, inviteId: string, onSaved: () => void) {
246 await revokeSpaceInvite(spaceName, inviteId)
247 onSaved()
248}
249
250/** A code from an email link works whether or not you are already a member. */
251function RedeemInvite({ busy }: { busy: boolean }) {
252 const { t } = useT()
253 const [code, setCode] = useState('')
254 const { run, busy: rowBusy, notice, error } = useSave()
255 return (
256 <div className="mt-8 border-t border-shell-hairline pt-5">
257 <h3 className="mb-1 text-[14px] font-bold">{t('space.invites.redeemTitle')}</h3>
258 <p className="mb-3 text-[12.5px] text-shell-ink-muted">{t('space.invites.redeemNote')}</p>
259 <div className="flex flex-wrap items-center gap-2">
260 <input
261 className={`${inputCls} h-9 max-w-[300px]`}
262 placeholder={t('space.invites.code')}
263 value={code}
264 onChange={e => setCode(e.target.value)}
265 aria-label={t('space.invites.code')}
266 />
267 <Button
268 variant="secondary"
269 className="h-9 text-[12.5px]"
270 disabled={busy || rowBusy || !code.trim()}
271 onClick={() =>
272 void run(async () => {
273 await acceptSpaceInvite({ code: code.trim() })
274 setCode('')
275 }, t('space.invites.redeemed'))
276 }
277 >
278 {t('space.join')}
279 </Button>
280 </div>
281 {notice ? <div className="mt-3">
282 <Alert tone="success">{notice}</Alert>
283 </div> : null}
284 {error ? <div className="mt-3">
285 <Alert tone="error">{error}</Alert>
286 </div> : null}
287 </div>
288 )
289}
Deleted web/src/features/space-management/ui/space-settings/members-section.tsx web/src/features/space-management/ui/space-settings/members-section.tsx +0 −304
@@ -1,304 +0,0 @@
1import { useState } from 'react'
2import { ChevronDown } from 'lucide-react'
3import {
4 Alert,
5 Button,
6 EmptyNote,
7 Field,
8 InitialAvatar,
9 SaveBar,
10 SectionTitle,
11 SettingRow,
12 Toggle,
13 fmtDate,
14 inputCls,
15 toDateInput,
16 useSave,
17} from '@/shared/ui'
18import { spaceAdminMembersKey, useAsync } from '@/shared/lib'
19import { useT } from '@/shared/i18n'
20import {
21 addSpaceMember,
22 findUserByUsername,
23 listSpaceMembers,
24 removeSpaceMember,
25 updateSpaceMember,
26 type SpaceMember,
27} from '../../model/space-admin'
28import { MEMBER_STATUS_LABELS, NOTIFICATION_OPTIONS, userLabel } from './meta'
29import type { SpaceSectionProps } from './props'
30
31/** The people in the community, with their per-space nickname and alerts. */
32export function MembersSection({ spaceName }: SpaceSectionProps) {
33 const { t, tc } = useT()
34 const [tick, setTick] = useState(0)
35 const [query, setQuery] = useState('')
36 const [handle, setHandle] = useState('')
37 const [openId, setOpenId] = useState<string | null>(null)
38 const { busy, notice, error, run } = useSave()
39 const members = useAsync(() => listSpaceMembers(spaceName), [spaceName, tick], {
40 cacheKey: spaceAdminMembersKey(spaceName),
41 })
42 const refresh = () => setTick(t => t + 1)
43
44 const rows = (members.data ?? []).filter(member => {
45 const needle = query.trim().toLowerCase()
46 if (!needle) return true
47 const user = member.user
48 return (
49 (user?.username ?? '').toLowerCase().includes(needle) ||
50 (user?.display_name ?? '').toLowerCase().includes(needle) ||
51 (member.alias_name ?? '').toLowerCase().includes(needle)
52 )
53 })
54
55 return (
56 <section>
57 <SectionTitle
58 title={t('space.members.title')}
59 sub={tc('space.members.count', members.data?.length ?? 0)}
60 />
61 <div className="mb-4 flex flex-wrap items-center gap-2">
62 <input
63 className={`${inputCls} h-9 max-w-[240px]`}
64 placeholder={t('space.members.searchPlaceholder')}
65 value={query}
66 onChange={e => setQuery(e.target.value)}
67 aria-label={t('space.members.searchAria')}
68 />
69 <input
70 className={`${inputCls} h-9 max-w-[220px]`}
71 placeholder={t('space.members.usernamePlaceholder')}
72 value={handle}
73 onChange={e => setHandle(e.target.value)}
74 onKeyDown={e => {
75 if (e.key === 'Enter') e.preventDefault()
76 }}
77 aria-label={t('space.members.usernameAria')}
78 />
79 <Button
80 variant="secondary"
81 className="h-9 text-[12.5px]"
82 disabled={busy || !handle.trim()}
83 onClick={() =>
84 void run(async () => {
85 const found = await findUserByUsername(handle.trim())
86 await addSpaceMember(spaceName, { user_id: found.user_id })
87 setHandle('')
88 refresh()
89 }, t('space.members.added'))
90 }
91 >
92 {t('space.members.add')}
93 </Button>
94 </div>
95 {error ? (
96 <div className="mb-3">
97 <Alert tone="error">{error}</Alert>
98 </div>
99 ) : null}
100 {notice && !error ? (
101 <div className="mb-3">
102 <Alert tone="success">{notice}</Alert>
103 </div>
104 ) : null}
105
106 {members.loading ? (
107 <EmptyNote>{t('space.members.loading')}</EmptyNote>
108 ) : rows.length === 0 ? (
109 <EmptyNote>{query ? t('space.members.emptyFiltered') : t('space.members.empty')}</EmptyNote>
110 ) : (
111 <div className="space-y-2">
112 {rows.map(member => {
113 const userId = member.user?.user_id ?? null
114 return (
115 <MemberRow
116 key={userId ?? member.joined_at}
117 spaceName={spaceName}
118 member={member}
119 expanded={userId !== null && openId === userId}
120 busy={busy}
121 onToggle={() => setOpenId(openId === userId ? null : userId)}
122 onSaved={refresh}
123 />
124 )
125 })}
126 </div>
127 )}
128 </section>
129 )
130}
131
132function MemberRow({
133 spaceName,
134 member,
135 expanded,
136 busy,
137 onToggle,
138 onSaved,
139}: {
140 spaceName: string
141 member: SpaceMember
142 expanded: boolean
143 busy: boolean
144 onToggle: () => void
145 onSaved: () => void
146}) {
147 const userId = member.user?.user_id
148 const [draft, setDraft] = useState(() => ({
149 alias_name: member.alias_name ?? '',
150 message_notifications: member.message_notifications,
151 muted: member.muted,
152 mute_until: toDateInput(member.mute_until),
153 mobile_push: member.mobile_push,
154 suppress_everyone: member.suppress_everyone,
155 suppress_roles: member.suppress_roles,
156 hide_muted_channels: member.hide_muted_channels,
157 }))
158 const { t } = useT()
159 const { run, busy: rowBusy, notice, error } = useSave()
160 const name = member.user?.display_name || member.user?.username || t('common.state.unknownUser')
161
162 return (
163 <div className="settings-card">
164 <SettingRow
165 lead={<InitialAvatar name={name} url={member.alias_avatar_url ?? member.user?.avatar_url} />}
166 title={
167 member.alias_name ? (
168 <>
169 {member.alias_name}
170 <span className="ml-2 font-normal text-shell-ink-muted">{userLabel(member.user)}</span>
171 </>
172 ) : (
173 userLabel(member.user)
174 )
175 }
176 meta={
177 <>
178 {MEMBER_STATUS_LABELS[member.status] ?? member.status} · {t('space.members.joinedAt', { date: fmtDate(member.joined_at) })}
179 {member.muted ? ` · ${t('space.members.muted')}` : ''}
180 </>
181 }
182 actions={
183 <button
184 type="button"
185 className="settings-disclose"
186 aria-expanded={expanded}
187 onClick={onToggle}
188 >
189 {expanded ? t('space.members.collapse') : t('space.members.expand')}
190 <ChevronDown className={`h-3.5 w-3.5 ${expanded ? 'rotate-180' : ''}`} aria-hidden />
191 </button>
192 }
193 />
194 {expanded ? (
195 <div className="settings-subpanel">
196 <div className="grid gap-x-4 sm:grid-cols-2">
197 <Field label={t('space.alias.label')} hint={t('space.alias.hint')}>
198 <input
199 className={`${inputCls} max-w-none`}
200 maxLength={64}
201 value={draft.alias_name}
202 onChange={e => setDraft({ ...draft, alias_name: e.target.value })}
203 />
204 </Field>
205 <Field label={t('space.alerts.label')}>
206 <select
207 className={`${inputCls} max-w-none`}
208 value={draft.message_notifications}
209 onChange={e =>
210 setDraft({
211 ...draft,
212 message_notifications: e.target.value as SpaceMember['message_notifications'],
213 })
214 }
215 >
216 {NOTIFICATION_OPTIONS.map(o => (
217 <option key={o.value} value={o.value}>
218 {o.label}
219 </option>
220 ))}
221 </select>
222 </Field>
223 </div>
224 <Toggle
225 checked={draft.muted}
226 onChange={v => setDraft({ ...draft, muted: v })}
227 label={t('space.members.mute')}
228 />
229 {draft.muted ? (
230 <Field label={t('space.members.muteUntil')} hint={t('space.members.muteForever')}>
231 <input
232 type="datetime-local"
233 className={inputCls}
234 value={draft.mute_until}
235 onChange={e => setDraft({ ...draft, mute_until: e.target.value })}
236 />
237 </Field>
238 ) : null}
239 <div className="grid gap-x-6 sm:grid-cols-2">
240 <Toggle
241 checked={draft.mobile_push}
242 onChange={v => setDraft({ ...draft, mobile_push: v })}
243 label={t('space.members.allowPush')}
244 />
245 <Toggle
246 checked={draft.suppress_everyone}
247 onChange={v => setDraft({ ...draft, suppress_everyone: v })}
248 label={t('space.alerts.suppressEveryone')}
249 />
250 <Toggle
251 checked={draft.suppress_roles}
252 onChange={v => setDraft({ ...draft, suppress_roles: v })}
253 label={t('space.alerts.suppressRoles')}
254 />
255 <Toggle
256 checked={draft.hide_muted_channels}
257 onChange={v => setDraft({ ...draft, hide_muted_channels: v })}
258 label={t('space.alerts.hideMuted')}
259 />
260 </div>
261 <SaveBar
262 busy={busy || rowBusy}
263 notice={notice}
264 error={error}
265 label={t('space.members.save')}
266 onSave={() =>
267 void run(async () => {
268 if (!userId) throw new Error(t('space.members.missingUser'))
269 await updateSpaceMember(spaceName, userId, {
270 alias_name: draft.alias_name.trim() || null,
271 message_notifications: draft.message_notifications,
272 muted: draft.muted,
273 mute_until: draft.muted ? (draft.mute_until ? new Date(draft.mute_until).toISOString() : null) : undefined,
274 mobile_push: draft.mobile_push,
275 suppress_everyone: draft.suppress_everyone,
276 suppress_roles: draft.suppress_roles,
277 hide_muted_channels: draft.hide_muted_channels,
278 })
279 onSaved()
280 }, t('space.members.saved'))
281 }
282 />
283 {userId ? (
284 <div className="mt-4 border-t border-shell-hairline pt-4">
285 <Button
286 variant="secondary"
287 className="h-8 text-[12.5px]"
288 disabled={busy || rowBusy}
289 onClick={() =>
290 void run(async () => {
291 await removeSpaceMember(spaceName, userId)
292 onSaved()
293 }, t('space.members.removed'))
294 }
295 >
296 {t('space.members.remove')}
297 </Button>
298 </div>
299 ) : null}
300 </div>
301 ) : null}
302 </div>
303 )
304}
Modified web/src/features/space-management/ui/space-settings/roles-section.tsx web/src/features/space-management/ui/space-settings/roles-section.tsx +318 −216 View web/src/features/space-management/ui/space-settings/roles-section.tsx
@@ -1,19 +1,18 @@
11import { useState } from 'react'
2import { ArrowLeft, Plus, Search } from 'lucide-react'
23import {
34 Alert,
45 Button,
5 Chip,
6 Checkbox,
67 EmptyNote,
78 Field,
89 SaveBar,
910 SectionTitle,
10 SettingRow,
11 Toggle,
1211 fmtDate,
1312 inputCls,
1413 useSave,
1514} from '@/shared/ui'
16import { spaceAdminKey, spaceAdminMembersKey, useAsync } from '@/shared/lib'
15import { spaceAdminMembersKey, useAsync } from '@/shared/lib'
1716import { useT } from '@/shared/i18n'
1817import {
1918 assignSpaceGroup,
@@ -21,128 +20,212 @@
2120 deleteSpaceGroup,
2221 joinSpaceGroup,
2322 leaveSpaceGroup,
24 listSpaceGroups,
23 listSpaceMembers,
2524 unassignSpaceGroup,
2625 updateSpaceGroup,
2726 type SpaceGroup,
2827} from '../../model/space-admin'
29import { listSpaceMembers } from '../../model/space-admin'
3028import { ALL_PERMISSION_KEYS, normalizeHexColor, permissionLabel, userLabel } from './meta'
31import type { SpaceSectionProps } from './props'
3229
33/** Roles: what a group is called, how it looks, and what it may do. */
34export function RolesSection({ spaceName }: SpaceSectionProps) {
30/**
31 * Roles' own sub-navigation: it replaces the settings section list while the
32 * roles pane is open — title, back, create, then one row per role.
33 */
34export function RolesNav({
35 groups,
36 loading,
37 selectedId,
38 creating,
39 onSelect,
40 onCreate,
41 onBack,
42}: {
43 groups: SpaceGroup[]
44 loading: boolean
45 selectedId: string | null
46 creating: boolean
47 onSelect: (id: string) => void
48 onCreate: () => void
49 onBack: () => void
50}) {
51 const { t } = useT()
52 return (
53 <div className="flex min-h-0 flex-1 flex-col">
54 <p className="settings-nav-title">{t('space.roles.title')}</p>
55 <button type="button" onClick={onBack} className="settings-nav-item">
56 <ArrowLeft className="h-4 w-4 shrink-0" aria-hidden />
57 <span className="truncate">{t('space.roles.back')}</span>
58 </button>
59 <button
60 type="button"
61 onClick={onCreate}
62 className={`settings-nav-item ${creating ? 'bg-shell-active text-shell-ink' : ''}`}
63 >
64 <Plus className="h-4 w-4 shrink-0" aria-hidden />
65 <span className="truncate">{t('space.roles.create')}</span>
66 </button>
67 <div className="mt-1 min-h-0 flex-1 space-y-px overflow-y-auto">
68 {loading ? (
69 <p className="px-2 py-3 text-[13px] text-shell-ink-muted">{t('space.roles.loading')}</p>
70 ) : null}
71 {!loading && groups.length === 0 ? (
72 <p className="px-2 py-3 text-[13px] text-shell-ink-muted">{t('space.roles.empty')}</p>
73 ) : null}
74 {groups.map(group => (
75 <button
76 key={group.id}
77 type="button"
78 aria-current={!creating && selectedId === group.id ? 'page' : undefined}
79 onClick={() => onSelect(group.id)}
80 className="settings-nav-item"
81 >
82 <span
83 aria-hidden
84 className="grid h-4 w-4 shrink-0 place-items-center rounded-full text-[10px]"
85 style={{ background: `${normalizeHexColor(group.color)}33` }}
86 >
87 {group.unicode_emoji || (
88 <span
89 className="h-2 w-2 rounded-full"
90 style={{ background: normalizeHexColor(group.color) }}
91 />
92 )}
93 </span>
94 <span className="truncate">{group.name}</span>
95 </button>
96 ))}
97 </div>
98 </div>
99 )
100}
101
102/** Roles pane: create form or the selected role's editor. */
103export function RolesSection({
104 spaceName,
105 groups,
106 loading,
107 roleId,
108 creating,
109 onRefresh,
110 onCreated,
111}: {
112 spaceName: string
113 groups: SpaceGroup[]
114 loading: boolean
115 roleId: string | null
116 creating: boolean
117 onRefresh: () => void
118 onCreated: (id?: string) => void
119}) {
35120 const { t } = useT()
36 const [tick, setTick] = useState(0)
37 const [draft, setDraft] = useState({ name: '', color: '#6b7280', unicode_emoji: '' })
38 const { busy, notice, error, run } = useSave()
39 const groups = useAsync(() => listSpaceGroups(spaceName), [spaceName, tick], {
40 cacheKey: spaceAdminKey(spaceName, 'groups'),
41 })
121 const [tick] = useState(0)
42122 const members = useAsync(() => listSpaceMembers(spaceName), [spaceName, tick], {
43123 cacheKey: spaceAdminMembersKey(spaceName),
44124 })
45 const refresh = () => setTick(t => t + 1)
125 const selected = groups.find(group => group.id === roleId) ?? groups[0] ?? null
126 const memberOptions = (members.data ?? [])
127 .filter(member => member.user)
128 .map(member => ({ value: member.user!.user_id, label: userLabel(member.user!) }))
46129
47130 return (
48131 <section>
49132 <SectionTitle title={t('space.roles.title')} sub={t('space.roles.sub')} />
50 <div className="mb-6 flex flex-wrap items-end gap-2">
51 <input
52 className={`${inputCls} h-9 max-w-[200px]`}
53 placeholder={t('space.roles.namePlaceholder')}
54 maxLength={64}
55 value={draft.name}
56 onChange={e => setDraft({ ...draft, name: e.target.value })}
57 aria-label={t('space.roles.nameAria')}
133 {creating ? (
134 <CreateRole spaceName={spaceName} onDone={onCreated} />
135 ) : loading && !selected ? (
136 <EmptyNote>{t('space.roles.loading')}</EmptyNote>
137 ) : selected ? (
138 <RoleEditor
139 key={selected.id}
140 spaceName={spaceName}
141 group={selected}
142 memberOptions={memberOptions}
143 onSaved={onRefresh}
58144 />
59 <label className="flex items-center gap-1.5 text-[12px] text-shell-ink-muted">
60 {t('space.roles.color')}
145 ) : (
146 <EmptyNote>{t('space.roles.emptyHint')}</EmptyNote>
147 )}
148 </section>
149 )
150}
151
152function CreateRole({ spaceName, onDone }: { spaceName: string; onDone: (id?: string) => void }) {
153 const { t } = useT()
154 const [draft, setDraft] = useState({ name: '', color: '#6b7280', unicode_emoji: '' })
155 const { busy, notice, error, run } = useSave()
156
157 return (
158 <div className="rounded-xl bg-shell-field p-4">
159 <h3 className="text-[15px] font-semibold">{t('space.roles.create')}</h3>
160 <div className="mt-3 grid gap-3 sm:grid-cols-[minmax(0,1fr)_auto_auto]">
161 <Field label={t('space.roles.name')}>
162 <input
163 className={inputCls}
164 placeholder={t('space.roles.namePlaceholder')}
165 maxLength={64}
166 value={draft.name}
167 onChange={e => setDraft({ ...draft, name: e.target.value })}
168 aria-label={t('space.roles.nameAria')}
169 />
170 </Field>
171 <Field label={t('space.roles.color')}>
61172 <input
62173 type="color"
63 className="h-9 w-12 cursor-pointer rounded-md border border-shell-hairline bg-shell-field px-1"
174 className="h-10 w-14 cursor-pointer rounded-md border border-shell-hairline bg-shell-field px-1"
64175 value={normalizeHexColor(draft.color)}
65176 onChange={e => setDraft({ ...draft, color: e.target.value })}
66177 aria-label={t('space.roles.colorAria')}
67178 />
68 </label>
69 <input
70 className={`${inputCls} h-9 w-16 text-center`}
71 placeholder="🙂"
72 maxLength={8}
73 value={draft.unicode_emoji}
74 onChange={e => setDraft({ ...draft, unicode_emoji: e.target.value })}
75 aria-label={t('space.roles.emojiAria')}
76 />
179 </Field>
180 <Field label={t('space.roles.emoji')}>
181 <input
182 className={`${inputCls} w-16 text-center`}
183 placeholder="🙂"
184 maxLength={8}
185 value={draft.unicode_emoji}
186 onChange={e => setDraft({ ...draft, unicode_emoji: e.target.value })}
187 aria-label={t('space.roles.emojiAria')}
188 />
189 </Field>
190 </div>
191 <div className="mt-3 flex items-center gap-2">
77192 <Button
78 variant="secondary"
79 className="h-9 text-[12.5px]"
193 variant="primary"
194 className="h-9 text-[13px]"
80195 disabled={busy || !draft.name.trim()}
81196 onClick={() =>
82197 void run(async () => {
83 await createSpaceGroup(spaceName, {
198 const created = await createSpaceGroup(spaceName, {
84199 name: draft.name.trim(),
85200 color: normalizeHexColor(draft.color),
86201 unicode_emoji: draft.unicode_emoji.trim() || null,
87202 permissions: [],
88203 })
89 setDraft({ name: '', color: '#6b7280', unicode_emoji: '' })
90 refresh()
204 onDone(created?.id)
91205 }, t('space.roles.created'))
92206 }
93207 >
94208 {t('space.roles.create')}
95209 </Button>
210 <Button variant="ghost" className="h-9 text-[13px]" onClick={() => onDone()}>
211 {t('space.roles.cancel')}
212 </Button>
96213 </div>
97 {error ? (
98 <div className="mb-3">
99 <Alert tone="error">{error}</Alert>
100 </div>
101 ) : null}
102 {notice && !error ? (
103 <div className="mb-3">
104 <Alert tone="success">{notice}</Alert>
105 </div>
106 ) : null}
107
108 {groups.loading ? (
109 <EmptyNote>{t('space.roles.loading')}</EmptyNote>
110 ) : (groups.data ?? []).length === 0 ? (
111 <EmptyNote>{t('space.roles.empty')}</EmptyNote>
112 ) : (
113 <div className="space-y-2">
114 {groups.data!.map(group => (
115 <GroupRow
116 key={group.id}
117 spaceName={spaceName}
118 group={group}
119 memberOptions={(members.data ?? [])
120 .filter(member => member.user)
121 .map(member => ({
122 value: member.user!.user_id,
123 label: userLabel(member.user),
124 }))}
125 busy={busy}
126 onSaved={refresh}
127 />
128 ))}
129 </div>
130 )}
131 </section>
214 {error ? <div className="mt-3"><Alert tone="error">{error}</Alert></div> : null}
215 {notice && !error ? <div className="mt-3"><Alert tone="success">{notice}</Alert></div> : null}
216 </div>
132217 )
133218}
134219
135function GroupRow({
220function RoleEditor({
136221 spaceName,
137222 group,
138223 memberOptions,
139 busy,
140224 onSaved,
141225}: {
142226 spaceName: string
143227 group: SpaceGroup
144228 memberOptions: { value: string; label: string }[]
145 busy: boolean
146229 onSaved: () => void
147230}) {
148231 const [name, setName] = useState(group.name)
@@ -150,9 +233,10 @@
150233 const [emoji, setEmoji] = useState(group.unicode_emoji ?? '')
151234 const [position, setPosition] = useState(String(group.position))
152235 const [perms, setPerms] = useState<Set<string>>(() => new Set(group.permissions))
236 const [query, setQuery] = useState('')
153237 const [target, setTarget] = useState('')
154 const { t, tc } = useT()
155 const { run, busy: rowBusy, notice, error } = useSave()
238 const { t } = useT()
239 const { run, busy, notice, error } = useSave()
156240 const dirty =
157241 name !== group.name ||
158242 color !== normalizeHexColor(group.color) ||
@@ -169,82 +253,96 @@
169253 return next
170254 })
171255
256 const needle = query.trim().toLowerCase()
257 const visiblePerms = ALL_PERMISSION_KEYS.filter(key =>
258 needle ? permissionLabel(key).toLowerCase().includes(needle) || key.includes(needle) : true,
259 )
260
172261 return (
173 <div className="settings-card">
174 <SettingRow
175 lead={
176 <span
177 aria-hidden
178 className="grid h-[30px] w-[30px] shrink-0 place-items-center rounded-full border border-shell-hairline text-[14px]"
179 style={{ background: `${color}1f` }}
180 >
181 {emoji || null}
182 </span>
183 }
184 title={name || group.name}
185 meta={
186 <>
187 {group.permissions.length === 0
188 ? t('space.roles.noPermissions')
189 : group.permissions.map(permissionLabel).join(' · ')}
190 {` · ${t('space.roles.createdAt', { date: fmtDate(group.created_at) })}`}
191 </>
192 }
193 actions={
194 <Chip>
195 <span
196 aria-hidden
197 className="h-2 w-2 rounded-full"
198 style={{ background: normalizeHexColor(group.color) }}
262 <div className="min-w-0">
263 <h3 className="text-[15px] font-semibold">{t('space.roles.editTitle', { name: group.name })}</h3>
264 <p className="mt-0.5 text-[12.5px] text-shell-ink-muted">{t('space.roles.editorSub')}</p>
265
266 <p className="mt-5 font-mono text-[10px] font-bold uppercase tracking-[1.2px] text-shell-ink-muted">
267 {t('space.roles.settingsGroup')}
268 </p>
269 <div className="mt-2 grid gap-x-4 sm:grid-cols-2">
270 <Field label={t('space.roles.name')}>
271 <input className={inputCls} maxLength={64} value={name} onChange={e => setName(e.target.value)} />
272 </Field>
273 <Field label={t('space.roles.color')} hint={t('space.roles.colorHint')}>
274 <div className="flex items-center gap-2">
275 <input
276 type="color"
277 className="h-10 w-14 shrink-0 cursor-pointer rounded-md border border-shell-hairline bg-shell-field px-1"
278 value={color}
279 onChange={e => setColor(e.target.value)}
280 aria-label={t('space.roles.colorAria2')}
199281 />
200 {tc('space.roles.permissionCount', group.permissions.length)}
201 </Chip>
202 }
203 />
204 <div className="settings-subpanel">
205 <div className="grid gap-x-4 sm:grid-cols-2">
206 <Field label={t('space.roles.name')}>
207 <input className={inputCls} maxLength={64} value={name} onChange={e => setName(e.target.value)} />
208 </Field>
209 <Field label={t('space.roles.color')}>
210 <div className="flex items-center gap-2">
211 <input
212 type="color"
213 className="h-10 w-14 cursor-pointer rounded-md border border-shell-hairline bg-shell-field px-1"
214 value={color}
215 onChange={e => setColor(e.target.value)}
216 aria-label={t('space.roles.colorAria2')}
217 />
218 <code className="text-[12px] text-shell-ink-muted">{color}</code>
219 </div>
220 </Field>
221 <Field label={t('space.roles.emoji')} hint={t('space.roles.emojiHint')}>
222 <input className={inputCls} maxLength={8} value={emoji} onChange={e => setEmoji(e.target.value)} />
223 </Field>
224 <Field label={t('space.roles.position')} hint={t('space.roles.positionHint')}>
225282 <input
226 type="number"
227 className={inputCls}
228 value={position}
229 onChange={e => setPosition(e.target.value)}
283 className={`${inputCls} font-mono text-[12.5px]`}
284 value={color}
285 onChange={e => setColor(e.target.value)}
286 maxLength={32}
230287 />
231 </Field>
232 </div>
233 <p className="mb-2 font-mono text-[10px] font-bold uppercase tracking-[1.2px] text-shell-ink-muted">
288 </div>
289 </Field>
290 <Field label={t('space.roles.emoji')} hint={t('space.roles.emojiHint')}>
291 <input className={inputCls} maxLength={8} value={emoji} onChange={e => setEmoji(e.target.value)} />
292 </Field>
293 <Field label={t('space.roles.position')} hint={t('space.roles.positionHint')}>
294 <input type="number" className={inputCls} value={position} onChange={e => setPosition(e.target.value)} />
295 </Field>
296 </div>
297
298 <div className="mt-5 flex flex-wrap items-center gap-2">
299 <p className="mr-auto font-mono text-[10px] font-bold uppercase tracking-[1.2px] text-shell-ink-muted">
234300 {t('space.roles.permissions')}
235301 </p>
236 <div className="mb-4 grid gap-x-4 sm:grid-cols-2">
237 {ALL_PERMISSION_KEYS.map(key => (
238 <Toggle
239 key={key}
302 <Button
303 variant="secondary"
304 className="h-8 text-[12.5px]"
305 disabled={busy || perms.size === 0}
306 onClick={() => setPerms(new Set())}
307 >
308 {t('space.roles.clearPermissions')}
309 </Button>
310 </div>
311 <label className="mt-2 flex h-9 items-center gap-2 rounded-lg border border-shell-hairline bg-shell-field px-2.5">
312 <Search className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
313 <input
314 value={query}
315 onChange={e => setQuery(e.target.value)}
316 placeholder={t('space.roles.permissionSearch')}
317 aria-label={t('space.roles.permissionSearch')}
318 className="w-full min-w-0 bg-transparent text-[13px] outline-none"
319 />
320 </label>
321 <p className="mt-4 px-1 text-[11px] font-bold uppercase tracking-[0.06em] text-shell-ink-muted">
322 {t('space.roles.scopeCommunity')}
323 </p>
324 <div className="mt-1">
325 {visiblePerms.length === 0 ? (
326 <p className="px-1 py-3 text-[13px] text-shell-ink-muted">{t('space.roles.noPermissionMatch')}</p>
327 ) : null}
328 {visiblePerms.map(key => (
329 <label
330 key={key}
331 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"
332 >
333 <span className="min-w-0 truncate">{permissionLabel(key)}</span>
334 <Checkbox
240335 checked={perms.has(key)}
241336 onChange={() => togglePerm(key)}
242 label={permissionLabel(key)}
337 aria-label={permissionLabel(key)}
243338 />
244 ))}
245 </div>
339 </label>
340 ))}
341 </div>
342
343 <div className="mt-4">
246344 <SaveBar
247 busy={busy || rowBusy}
345 busy={busy}
248346 notice={notice}
249347 error={error}
250348 label={t('space.roles.save')}
@@ -252,7 +350,7 @@
252350 void run(async () => {
253351 await updateSpaceGroup(spaceName, group.id, {
254352 name: name.trim() || group.name,
255 color,
353 color: normalizeHexColor(color),
256354 unicode_emoji: emoji.trim() || null,
257355 position: Number.isFinite(Number(position)) ? Number(position) : group.position,
258356 permissions: [...perms],
@@ -262,83 +360,87 @@
262360 }
263361 extra={dirty ? <span className="text-[12px] text-shell-ink-muted">{t('space.roles.dirty')}</span> : undefined}
264362 />
265 <div className="mt-5 flex flex-wrap items-center gap-2 border-t border-shell-hairline pt-4">
266 <select
267 className={`${inputCls} h-9 w-auto max-w-[220px]`}
268 value={target}
269 onChange={e => setTarget(e.target.value)}
270 aria-label={t('space.roles.selectMember')}
271 >
272 <option value="">{t('space.roles.selectMemberPlaceholder')}</option>
273 {memberOptions.map(option => (
274 <option key={option.value} value={option.value}>
275 {option.label}
276 </option>
277 ))}
278 </select>
279 <Button
280 variant="secondary"
281 className="h-8 text-[12.5px]"
282 disabled={busy || rowBusy || !target}
283 onClick={() =>
284 void run(async () => {
285 await assignSpaceGroup(spaceName, target, group.id)
286 setTarget('')
287 onSaved()
288 }, t('space.roles.granted'))
289 }
290 >
291 {t('space.roles.grant')}
292 </Button>
363 </div>
364
365 <div className="mt-5 flex flex-wrap items-center gap-2 border-t border-shell-hairline pt-4">
366 <select
367 className={`${inputCls} h-9 w-auto max-w-[220px]`}
368 value={target}
369 onChange={e => setTarget(e.target.value)}
370 aria-label={t('space.roles.selectMember')}
371 >
372 <option value="">{t('space.roles.selectMemberPlaceholder')}</option>
373 {memberOptions.map(option => (
374 <option key={option.value} value={option.value}>
375 {option.label}
376 </option>
377 ))}
378 </select>
379 <Button
380 variant="secondary"
381 className="h-8 text-[12.5px]"
382 disabled={busy || !target}
383 onClick={() =>
384 void run(async () => {
385 await assignSpaceGroup(spaceName, target, group.id)
386 setTarget('')
387 onSaved()
388 }, t('space.roles.granted'))
389 }
390 >
391 {t('space.roles.grant')}
392 </Button>
393 <Button
394 variant="ghost"
395 className="h-8 text-[12.5px]"
396 disabled={busy || !target}
397 onClick={() =>
398 void run(async () => {
399 await unassignSpaceGroup(spaceName, target, group.id)
400 setTarget('')
401 onSaved()
402 }, t('space.roles.revoked'))
403 }
404 >
405 {t('space.roles.revoke')}
406 </Button>
407 <span className="ml-auto flex items-center gap-2">
293408 <Button
294409 variant="ghost"
295410 className="h-8 text-[12.5px]"
296 disabled={busy || rowBusy || !target}
297 onClick={() =>
298 void run(async () => {
299 await unassignSpaceGroup(spaceName, target, group.id)
300 setTarget('')
301 onSaved()
302 }, t('space.roles.revoked'))
303 }
411 disabled={busy}
412 onClick={() => void run(() => joinSpaceGroup(spaceName, group.id), t('space.roles.joined'))}
304413 >
305 {t('space.roles.revoke')}
414 {t('space.roles.join')}
306415 </Button>
307 <span className="ml-auto flex items-center gap-2">
308 <Button
309 variant="ghost"
310 className="h-8 text-[12.5px]"
311 disabled={busy || rowBusy}
312 onClick={() => void run(() => joinSpaceGroup(spaceName, group.id), t('space.roles.joined'))}
313 >
314 {t('space.roles.join')}
315 </Button>
316 <Button
317 variant="ghost"
318 className="h-8 text-[12.5px]"
319 disabled={busy || rowBusy}
320 onClick={() => void run(() => leaveSpaceGroup(spaceName, group.id), t('space.roles.left'))}
321 >
322 {t('space.roles.leave')}
323 </Button>
324 </span>
325 </div>
326 <p className="mt-3 text-[11.5px] text-shell-ink-muted">{t('space.roles.noReadback')}</p>
327 <div className="mt-4 border-t border-shell-hairline pt-4">
328416 <Button
329 variant="secondary"
417 variant="ghost"
330418 className="h-8 text-[12.5px]"
331 disabled={busy || rowBusy}
332 onClick={() =>
333 void run(async () => {
334 await deleteSpaceGroup(spaceName, group.id)
335 onSaved()
336 }, t('space.roles.deleted'))
337 }
419 disabled={busy}
420 onClick={() => void run(() => leaveSpaceGroup(spaceName, group.id), t('space.roles.left'))}
338421 >
339 {t('space.roles.delete')}
422 {t('space.roles.leave')}
340423 </Button>
341 </div>
424 </span>
425 </div>
426 <p className="mt-3 text-[11.5px] text-shell-ink-muted">{t('space.roles.noReadback')}</p>
427 <p className="mt-1 text-[11.5px] text-shell-ink-muted">
428 {t('space.roles.createdAt', { date: fmtDate(group.created_at) })}
429 </p>
430 <div className="mt-4 border-t border-shell-hairline pt-4">
431 <Button
432 variant="secondary"
433 className="h-8 text-[12.5px]"
434 disabled={busy}
435 onClick={() =>
436 void run(async () => {
437 await deleteSpaceGroup(spaceName, group.id)
438 onSaved()
439 }, t('space.roles.deleted'))
440 }
441 >
442 {t('space.roles.delete')}
443 </Button>
342444 </div>
343445 </div>
344446 )
Added web/src/features/space-management/ui/privacy-dialog.tsx web/src/features/space-management/ui/privacy-dialog.tsx +185 −0 View web/src/features/space-management/ui/privacy-dialog.tsx
@@ -0,0 +1,185 @@
1import { useEffect, useId, useRef, useState } from 'react'
2import { Check, X } from 'lucide-react'
3import { useT } from '@/shared/i18n'
4import { errText } from '@/shared/ui'
5import { fetchPreferences, updatePreferences, type UserPreferences } 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) =>
72 value ? value !== 'Nobody' : false
73 const rows: Array<{ key: Key; label: string; hint: string; checked: boolean; patch: (on: boolean) => Patch }> = [
74 {
75 key: 'allow_dms_from',
76 label: t('space.privacy.dms'),
77 hint: t('space.privacy.dmsHint'),
78 checked: audience(prefs?.allow_dms_from),
79 patch: on => ({ allow_dms_from: on ? 'Everyone' : 'Nobody' }),
80 },
81 {
82 key: 'allow_mentions_from',
83 label: t('space.privacy.mentions'),
84 hint: t('space.privacy.mentionsHint'),
85 checked: audience(prefs?.allow_mentions_from),
86 patch: on => ({ allow_mentions_from: on ? 'Everyone' : 'Nobody' }),
87 },
88 {
89 key: 'show_online_status',
90 label: t('space.privacy.online'),
91 hint: t('space.privacy.onlineHint'),
92 checked: prefs?.show_online_status ?? false,
93 patch: on => ({ show_online_status: on }),
94 },
95 ]
96
97 return (
98 <div
99 className="fixed inset-0 z-50 grid place-items-center bg-black/50 p-4"
100 onClick={() => closeRef.current()}
101 >
102 <div
103 role="dialog"
104 aria-modal
105 aria-labelledby={titleId}
106 onClick={event => event.stopPropagation()}
107 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%)]"
108 >
109 <div className="flex items-start justify-between gap-3">
110 <h2 id={titleId} className="text-[17px] font-bold">{t('space.privacy.title')}</h2>
111 <button
112 type="button"
113 aria-label={t('ui.close')}
114 onClick={() => closeRef.current()}
115 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"
116 >
117 <X className="h-4 w-4" />
118 </button>
119 </div>
120 <div className="space-y-0.5 pb-1 pt-1">
121 {rows.map(row => (
122 <div key={row.key} className="flex items-center justify-between gap-4 py-3">
123 <div className="min-w-0">
124 <p className="text-[14.5px] font-semibold">{row.label}</p>
125 <p className="mt-0.5 text-[12.5px] text-shell-ink-muted">{row.hint}</p>
126 </div>
127 <Switch
128 checked={row.checked}
129 disabled={!prefs}
130 label={row.label}
131 onChange={on => flip(row.patch(on))}
132 />
133 </div>
134 ))}
135 </div>
136 <p
137 aria-live="polite"
138 className={`h-4 truncate text-[11.5px] ${error ? 'text-[#f23f43]' : 'text-shell-ink-muted'}`}
139 >
140 {error ?? (pending > 0 ? t('ui.saving') : saved ? t('space.privacy.saved') : '')}
141 </p>
142 </div>
143 </div>
144 )
145}
146
147function Switch({
148 checked,
149 disabled,
150 label,
151 onChange,
152}: {
153 checked: boolean
154 disabled: boolean
155 label: string
156 onChange: (value: boolean) => void
157}) {
158 return (
159 <button
160 type="button"
161 role="switch"
162 aria-checked={checked}
163 aria-label={label}
164 disabled={disabled}
165 onClick={() => onChange(!checked)}
166 className={`relative h-[26px] w-[46px] shrink-0 rounded-full transition-colors duration-200 ${
167 checked ? 'bg-[#5865f2]' : 'bg-shell-tile'
168 } ${disabled ? 'opacity-50' : ''}`}
169 >
170 <span
171 aria-hidden
172 className={`absolute left-[3px] top-[3px] grid h-5 w-5 place-items-center rounded-full bg-white shadow-sm transition-transform duration-200 ${
173 checked ? 'translate-x-[20px]' : ''
174 }`}
175 >
176 <Check
177 strokeWidth={3.5}
178 className={`h-3 w-3 text-[#5865f2] transition-opacity duration-200 ${
179 checked ? 'opacity-100' : 'opacity-0'
180 }`}
181 />
182 </span>
183 </button>
184 )
185}
Modified web/src/features/space-management/ui/space-header.tsx web/src/features/space-management/ui/space-header.tsx +42 −71 View web/src/features/space-management/ui/space-header.tsx
@@ -1,4 +1,5 @@
11import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'
2import { Link } from 'react-router-dom'
23import {
34 ChevronDown,
45 Download,
@@ -18,17 +19,16 @@
1819 createRepo,
1920 createRoom,
2021 createSpaceDir,
21 fetchPreferences,
2222 fetchSpaceDir,
2323 leaveSpace,
24 updatePreferences,
2524} from '../model/api'
2625import type { MySpace } from '../model/api'
2726import { ImportRepoModal } from './import-repo-modal'
27import { PrivacyDialog } from './privacy-dialog'
2828import { findUserByUsername, getSpaceImage, listSpaceGroups } from '../model/space-admin'
2929import { FormModal, parseList } from '@/shared/ui'
3030import { spaceImageKey, useAsync } from '@/shared/lib'
31import { t as tr, useT } from '@/shared/i18n'
31import { useT } from '@/shared/i18n'
3232import { MANAGE_PERMS, parentDirOptions } from './space-settings/meta'
3333
3434type Dialog =
@@ -45,12 +45,6 @@
4545/** Repo/board/room slugs follow the space-name grammar (lowercase, digits, hyphen). */
4646const slug = (v: string) => v.toLowerCase().replace(/[^a-z0-9-]/g, '')
4747
48const AUDIENCE_OPTIONS = [
49 { value: 'Everyone', get label() { return tr('label.audience.everyone') } },
50 { value: 'FollowingOnly', get label() { return tr('label.audience.following') } },
51 { value: 'Nobody', get label() { return tr('label.audience.nobody') } },
52]
53
5448export interface SpaceHeaderProps {
5549 mySpace: MySpace
5650 viewerId?: string
@@ -184,13 +178,7 @@
184178 />
185179 ) : null}
186180 </div>
187 <button
188 type="button"
189 aria-haspopup="menu"
190 aria-expanded={menuOpen}
191 onClick={() => setMenuOpen(open => !open)}
192 className="space-community-menu-trigger"
193 >
181 <div className="space-community-menu-trigger">
194182 <span className="space-community-avatar" aria-hidden="true">
195183 {space.avatar_url ? (
196184 <img src={space.avatar_url} alt="" />
@@ -199,21 +187,43 @@
199187 )}
200188 </span>
201189 <span className="min-w-0 flex-1">
202 <span className="space-community-name">{space.name}</span>
203 <span className="space-community-members">{space.members_count}</span>
190 <button
191 type="button"
192 aria-haspopup="menu"
193 aria-expanded={menuOpen}
194 onClick={() => setMenuOpen(open => !open)}
195 className="block max-w-full text-left"
196 >
197 <span className="space-community-name">{space.name}</span>
198 </button>
199 <Link
200 to={`/app/${encodeURIComponent(space.name)}/members`}
201 title={t('space.members.open')}
202 aria-label={t('space.members.open')}
203 className="space-community-members"
204 >
205 {space.members_count}
206 </Link>
204207 </span>
205 <ChevronDown
206 className={`h-4 w-4 shrink-0 transition-transform ${
207 menuOpen ? 'rotate-180' : ''
208 }`}
209 aria-hidden
210 />
211 </button>
208 <button
209 type="button"
210 aria-label={space.name}
211 aria-haspopup="menu"
212 aria-expanded={menuOpen}
213 onClick={() => setMenuOpen(open => !open)}
214 className="shrink-0"
215 >
216 <ChevronDown
217 className={`h-4 w-4 transition-transform ${menuOpen ? 'rotate-180' : ''}`}
218 aria-hidden
219 />
220 </button>
221 </div>
212222
213223 {menuOpen ? (
214224 <div
215225 role="menu"
216 className="absolute left-3 right-3 top-full z-40 mt-2 rounded-lg border-2 border-shell-hairline bg-shell-surface p-1.5 shadow-xl"
226 className="absolute left-3 right-3 top-full z-40 mt-2 rounded-xl bg-shell-surface p-1 shadow-xl"
217227 >
218228 {perms.has('manage_invites') ? (
219229 <MenuItem icon={UserPlus} label={t('space.menu.invite')} onClick={() => openDialog('invite')} />
@@ -500,45 +510,8 @@
500510 }}
501511 onClose={closeDialog}
502512 />
513 {dialog === 'privacy' ? <PrivacyDialog onClose={closeDialog} /> : null}
503514 <FormModal
504 title={t('space.privacy.title')}
505 description={t('space.privacy.description')}
506 open={dialog === 'privacy'}
507 confirmLabel={t('common.action.save')}
508 load={async () => {
509 const prefs = await fetchPreferences()
510 return {
511 allow_dms_from: prefs.allow_dms_from,
512 allow_mentions_from: prefs.allow_mentions_from,
513 show_online_status: prefs.show_online_status,
514 }
515 }}
516 fields={[
517 {
518 kind: 'select',
519 key: 'allow_dms_from',
520 label: t('space.privacy.dms'),
521 options: AUDIENCE_OPTIONS,
522 },
523 {
524 kind: 'select',
525 key: 'allow_mentions_from',
526 label: t('space.privacy.mentions'),
527 options: AUDIENCE_OPTIONS,
528 },
529 { kind: 'toggle', key: 'show_online_status', label: t('space.privacy.online') },
530 ]}
531 onSubmit={async values => {
532 await updatePreferences({
533 allow_dms_from: String(values.allow_dms_from) as AudienceValue,
534 allow_mentions_from: String(values.allow_mentions_from) as AudienceValue,
535 show_online_status: Boolean(values.show_online_status),
536 })
537 return t('space.privacy.saved')
538 }}
539 onClose={closeDialog}
540 />
541 <FormModal
542515 title={t('space.leave.title')}
543516 description={t('space.leave.sub', { space: space.name })}
544517 open={dialog === 'leave'}
@@ -563,8 +536,6 @@
563536}
564537
565538type CreateRoomKind = 'Text' | 'Voice' | 'Stage'
566type AudienceValue = 'Everyone' | 'FollowingOnly' | 'Nobody'
567
568539function MenuItem({
569540 icon: Icon,
570541 label,
@@ -581,16 +552,16 @@
581552 type="button"
582553 role="menuitem"
583554 onClick={onClick}
584 className={`flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-[13.5px] transition-colors ${
585 danger ? 'text-[#f23f43] hover:bg-[#f23f43]/10' : 'hover:bg-shell-active'
555 className={`flex w-full items-center gap-3 rounded-md px-2.5 py-1.5 text-left text-[14px] font-medium transition-colors ${
556 danger ? 'text-[#f23f43] hover:bg-[#f23f43]/10' : 'text-shell-ink hover:bg-shell-active'
586557 }`}
587558 >
588 <Icon className="h-4 w-4 shrink-0" aria-hidden />
589 {label}
559 <span className="min-w-0 flex-1 truncate">{label}</span>
560 <Icon className={`h-[18px] w-[18px] shrink-0 ${danger ? '' : 'text-shell-ink'}`} aria-hidden />
590561 </button>
591562 )
592563}
593564
594565function MenuDivider(): ReactNode {
595 return <div className="my-1 h-px bg-shell-hairline" />
566 return <div className="my-0.5 h-px bg-shell-hairline" />
596567}
Modified web/src/features/space-management/ui/space-settings-modal.tsx web/src/features/space-management/ui/space-settings-modal.tsx +70 −19 View web/src/features/space-management/ui/space-settings-modal.tsx
@@ -1,20 +1,20 @@
11import { useEffect, useState } from 'react'
2import { Ban, ClipboardList, Image, KeyRound, Settings, Shield, Trash2, UserPlus, UserRound, Users } from 'lucide-react'
23import { Alert, Busy, SettingsModalShell, type SettingsSection } from '@/shared/ui'
34import { spaceAdminKey, useAsync } from '@/shared/lib'
45import { useT } from '@/shared/i18n'
56import { fetchSpace } from '../model/api'
7import { listSpaceGroups } from '../model/space-admin'
68import type { MySpace } from '../model/api'
79import { BansSection } from './space-settings/bans-section'
810import { ChannelPermissionsSection } from './space-settings/channel-permissions-section'
911import { DangerSection } from './space-settings/danger-section'
1012import { GeneralSection } from './space-settings/general-section'
1113import { ImagesSection } from './space-settings/images-section'
12import { InvitesSection } from './space-settings/invites-section'
1314import { JoinRequestsSection } from './space-settings/join-requests-section'
1415import { JoinRulesSection } from './space-settings/join-rules-section'
15import { MembersSection } from './space-settings/members-section'
1616import { MyMembershipSection } from './space-settings/my-membership-section'
17import { RolesSection } from './space-settings/roles-section'
17import { RolesNav, RolesSection } from './space-settings/roles-section'
1818import { PERM } from './space-settings/meta'
1919import type { SpaceSectionProps } from './space-settings/props'
2020
@@ -48,7 +48,16 @@
4848}: SpaceSettingsModalProps) {
4949 const { t } = useT()
5050 const [section, setSection] = useState('mine')
51 const [roleId, setRoleId] = useState<string | null>(null)
52 const [roleCreating, setRoleCreating] = useState(false)
53 const [rolesTick, setRolesTick] = useState(0)
5154 const { permissions } = mySpace
55 const rolesOpen = section === 'roles'
56 const roleGroups = useAsync(
57 () => (open && rolesOpen ? listSpaceGroups(mySpace.space.name) : Promise.resolve([])),
58 [open, rolesOpen, mySpace.space.name, rolesTick],
59 { cacheKey: open && rolesOpen ? spaceAdminKey(mySpace.space.name, 'groups') : null },
60 )
5261 const loaded = useAsync(
5362 () => (open ? fetchSpace(mySpace.space.name) : Promise.resolve(null)),
5463 [open, mySpace.space.name, mySpace.space.updated_at],
@@ -64,38 +73,46 @@
6473 const isOwner = Boolean(ownerUid && viewerId && ownerUid === viewerId)
6574 const perms = new Set(permissions)
6675
76 const communityGroup = t('space.settings.group.community')
77 const membersGroup = t('space.settings.group.members')
6778 const visible: SettingsSection[] = [
68 { id: 'mine', label: t('space.settings.sections.mine') },
69 { id: 'general', label: t('space.settings.sections.general') },
70 ...(perms.has(PERM.channels) ? [{ id: 'images', label: t('space.settings.sections.images') }] : []),
79 { id: 'mine', label: t('space.settings.sections.mine'), icon: UserRound, group: communityGroup },
80 { id: 'general', label: t('space.settings.sections.general'), icon: Settings, group: communityGroup },
81 ...(perms.has(PERM.channels) ? [{ id: 'images', label: t('space.settings.sections.images'), icon: Image, group: communityGroup }] : []),
7182 ...(perms.has(PERM.members)
7283 ? [
73 { id: 'members', label: t('space.members.title') },
74 { id: 'join-requests', label: t('space.settings.sections.joinRequests') },
84 {
85 id: 'members',
86 label: t('space.members.title'),
87 icon: Users,
88 group: membersGroup,
89 href: `/app/${encodeURIComponent(mySpace.space.name)}/members`,
90 },
91 { id: 'join-requests', label: t('space.settings.sections.joinRequests'), icon: UserPlus, group: membersGroup },
7592 ]
7693 : []),
77 ...(perms.has(PERM.roles) ? [{ id: 'roles', label: t('space.roles.title') }] : []),
78 ...(perms.has(PERM.invites) ? [{ id: 'invites', label: t('space.invites.title') }] : []),
79 ...(perms.has(PERM.roles) ? [{ id: 'join', label: t('space.settings.sections.join') }] : []),
80 ...(perms.has(PERM.bans) ? [{ id: 'bans', label: t('space.settings.sections.bans') }] : []),
94 ...(perms.has(PERM.roles) ? [{ id: 'roles', label: t('space.roles.title'), icon: Shield, group: membersGroup }] : []),
95 ...(perms.has(PERM.roles) ? [{ id: 'join', label: t('space.settings.sections.join'), icon: ClipboardList, group: membersGroup }] : []),
96 ...(perms.has(PERM.bans) ? [{ id: 'bans', label: t('space.settings.sections.bans'), icon: Ban, group: membersGroup }] : []),
8197 ...(perms.has(PERM.channels) || perms.has(PERM.overwrites)
82 ? [{ id: 'channels', label: t('space.channelPermissions.title') }]
98 ? [{ id: 'channels', label: t('space.channelPermissions.title'), icon: KeyRound, group: membersGroup }]
8399 : []),
84 { id: 'danger', label: t('space.settings.danger') },
100 { id: 'danger', label: t('space.settings.danger'), icon: Trash2 },
85101 ]
86102
87103 // A deep-linked section applies on each open, as long as this space's
88104 // permissions actually expose it.
105 const paneSections = visible.filter(item => !item.href)
89106 useEffect(() => {
90107 if (!open || !initialSection) return
91 if (visible.some(item => item.id === initialSection)) setSection(initialSection)
108 if (paneSections.some(item => item.id === initialSection)) setSection(initialSection)
92109 // eslint-disable-next-line react-hooks/exhaustive-deps
93110 }, [open, initialSection, mySpace.space.name])
94111
95112 // Losing the permission that owned the current section (or opening a
96113 // different space) must not leave the content column blank.
97114 useEffect(() => {
98 if (!visible.some(item => item.id === section)) setSection(visible[0].id)
115 if (!paneSections.some(item => item.id === section)) setSection(paneSections[0].id)
99116 // eslint-disable-next-line react-hooks/exhaustive-deps
100117 }, [section, permissions.join(','), mySpace.space.name])
101118
@@ -116,8 +133,30 @@
116133 title={t('space.settings.title')}
117134 sections={visible}
118135 active={section}
136 contentToken={
137 section === 'roles' ? `roles:${roleId ?? roleGroups.data?.[0]?.id ?? ''}` : section
138 }
119139 onSelect={setSection}
120140 onClose={onClose}
141 navOverride={
142 rolesOpen ? (
143 <RolesNav
144 groups={roleGroups.data ?? []}
145 loading={roleGroups.loading}
146 selectedId={roleId ?? roleGroups.data?.[0]?.id ?? null}
147 creating={roleCreating}
148 onSelect={id => {
149 setRoleCreating(false)
150 setRoleId(id)
151 }}
152 onCreate={() => setRoleCreating(true)}
153 onBack={() => {
154 setRoleCreating(false)
155 setSection(paneSections[0]?.id ?? 'mine')
156 }}
157 />
158 ) : undefined
159 }
121160 >
122161 {loaded.error && !loaded.data ? (
123162 <div className="mb-5">
@@ -127,10 +166,22 @@
127166 {section === 'mine' ? <MyMembershipSection {...props} /> : null}
128167 {section === 'general' ? full || !loaded.loading ? <GeneralSection {...props} /> : <Busy /> : null}
129168 {section === 'images' ? <ImagesSection {...props} /> : null}
130 {section === 'members' ? <MembersSection {...props} /> : null}
131169 {section === 'join-requests' ? <JoinRequestsSection {...props} /> : null}
132 {section === 'roles' ? <RolesSection {...props} /> : null}
133 {section === 'invites' ? <InvitesSection {...props} /> : null}
170 {section === 'roles' ? (
171 <RolesSection
172 spaceName={mySpace.space.name}
173 groups={roleGroups.data ?? []}
174 loading={roleGroups.loading}
175 roleId={roleId ?? roleGroups.data?.[0]?.id ?? null}
176 creating={roleCreating}
177 onRefresh={() => setRolesTick(value => value + 1)}
178 onCreated={id => {
179 setRoleCreating(false)
180 setRoleId(id ?? null)
181 setRolesTick(value => value + 1)
182 }}
183 />
184 ) : null}
134185 {section === 'join' ? <JoinRulesSection {...props} /> : null}
135186 {section === 'bans' ? <BansSection {...props} /> : null}
136187 {section === 'channels' ? <ChannelPermissionsSection {...props} /> : null}
Modified web/src/features/user-settings/ui/user-settings-modal.tsx web/src/features/user-settings/ui/user-settings-modal.tsx +12 −11 View web/src/features/user-settings/ui/user-settings-modal.tsx
@@ -1,4 +1,5 @@
11import { useState } from 'react'
2import { Activity, Bell, Eye, IdCard, KeyRound, Mail, MonitorSmartphone, Palette, ShieldCheck, Terminal, UserRound } from 'lucide-react'
23import { useT } from '@/shared/i18n'
34import { SettingsModalShell, type SettingsSection } from '@/shared/ui'
45import type { Me } from '../model/user-settings'
@@ -29,17 +30,17 @@
2930
3031 // Built per render so the section list follows the active language.
3132 const sections: SettingsSection[] = [
32 { id: 'account', label: t('settings.section.account') },
33 { id: 'profile', label: t('settings.section.profile') },
34 { id: 'security', label: t('settings.section.security') },
35 { id: 'sessions', label: t('settings.section.sessions') },
36 { id: 'emails', label: t('settings.section.emails') },
37 { id: 'privacy', label: t('settings.section.privacy') },
38 { id: 'notifications', label: t('settings.section.notifications') },
39 { id: 'appearance', label: t('settings.section.appearance') },
40 { id: 'keys', label: t('settings.section.keys') },
41 { id: 'sshKeys', label: t('settings.section.sshKeys') },
42 { id: 'status', label: t('settings.section.status') },
33 { id: 'account', label: t('settings.section.account'), icon: UserRound, group: t('settings.group.account') },
34 { id: 'profile', label: t('settings.section.profile'), icon: IdCard, group: t('settings.group.account') },
35 { id: 'security', label: t('settings.section.security'), icon: ShieldCheck, group: t('settings.group.account') },
36 { id: 'sessions', label: t('settings.section.sessions'), icon: MonitorSmartphone, group: t('settings.group.account') },
37 { id: 'emails', label: t('settings.section.emails'), icon: Mail, group: t('settings.group.account') },
38 { id: 'privacy', label: t('settings.section.privacy'), icon: Eye, group: t('settings.group.preferences') },
39 { id: 'notifications', label: t('settings.section.notifications'), icon: Bell, group: t('settings.group.preferences') },
40 { id: 'appearance', label: t('settings.section.appearance'), icon: Palette, group: t('settings.group.preferences') },
41 { id: 'keys', label: t('settings.section.keys'), icon: KeyRound, group: t('settings.group.advanced') },
42 { id: 'sshKeys', label: t('settings.section.sshKeys'), icon: Terminal, group: t('settings.group.advanced') },
43 { id: 'status', label: t('settings.section.status'), icon: Activity, group: t('settings.group.advanced') },
4344 ]
4445
4546 if (!open) return null
Modified web/src/pages/workspace/model/route.ts web/src/pages/workspace/model/route.ts +1 −1 View web/src/pages/workspace/model/route.ts
@@ -1,5 +1,5 @@
11/** Main-column variant, taken from the route's static section segment. */
2export type ShellMode = 'chat' | 'board' | 'repo' | 'notify' | 'explore'
2export type ShellMode = 'chat' | 'board' | 'repo' | 'members' | 'notify' | 'explore'
33
44/** Repo page tabs, rendered by `RepoLayout`'s default tab strip. */
55export type RepoTabId =
Modified web/src/pages/workspace/model/workspace-route.ts web/src/pages/workspace/model/workspace-route.ts +2 −0 View web/src/pages/workspace/model/workspace-route.ts
@@ -29,6 +29,8 @@
2929 ? 'notify'
3030 : section === 'board'
3131 ? 'board'
32 : section === 'members'
33 ? 'members'
3234 : section === 'repo'
3335 ? 'repo'
3436 : 'chat'
Added web/src/pages/workspace/ui/repo/clone-menu.tsx web/src/pages/workspace/ui/repo/clone-menu.tsx +166 −0 View web/src/pages/workspace/ui/repo/clone-menu.tsx
@@ -0,0 +1,166 @@
1import { useEffect, useRef, useState } from 'react'
2import { Check, ChevronDown, Copy, GitFork, Loader2 } from 'lucide-react'
3import { useT } from '@/shared/i18n'
4import { createArchive, type ArchiveFormat, type CloneTarget } from '@/entities/repo'
5
6const ARCHIVE_FORMATS: Array<{ value: ArchiveFormat; label: string }> = [
7 { value: 'zip', label: 'zip' },
8 { value: 'tar_gz', label: 'tar.gz' },
9 { value: 'tar_bz2', label: 'tar.bz2' },
10 { value: 'tar', label: 'tar' },
11]
12
13/**
14 * Toolbar "Clone" chip: opens a panel with the backend-provided SSH / HTTPS
15 * addresses, each row copying its own URL.
16 */
17export function RepoCloneMenu({
18 targets,
19 space,
20 repo,
21 rev,
22}: {
23 targets: CloneTarget[]
24 space: string
25 repo: string
26 rev: string
27}) {
28 const { t } = useT()
29 const [open, setOpen] = useState(false)
30 const [copied, setCopied] = useState<CloneTarget['protocol'] | null>(null)
31 const [busy, setBusy] = useState<ArchiveFormat | null>(null)
32 const [downloadFailed, setDownloadFailed] = useState(false)
33 const root = useRef<HTMLDivElement>(null)
34 const copyTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
35 // SSH first, matching the panel the reference UI shows.
36 const ordered = [...targets].sort((a, b) => Number(b.protocol === 'ssh') - Number(a.protocol === 'ssh'))
37
38 useEffect(() => () => clearTimeout(copyTimer.current), [])
39
40 useEffect(() => {
41 if (!open) return
42 const onDown = (event: PointerEvent) => {
43 if (!root.current?.contains(event.target as Node)) setOpen(false)
44 }
45 const onKey = (event: KeyboardEvent) => {
46 if (event.key === 'Escape') setOpen(false)
47 }
48 window.addEventListener('pointerdown', onDown)
49 window.addEventListener('keydown', onKey)
50 return () => {
51 window.removeEventListener('pointerdown', onDown)
52 window.removeEventListener('keydown', onKey)
53 }
54 }, [open])
55
56 async function copy(target: CloneTarget) {
57 try {
58 await navigator.clipboard.writeText(target.url)
59 } catch {
60 // Clipboard access can be denied; the label still confirms the attempt.
61 }
62 setCopied(target.protocol)
63 clearTimeout(copyTimer.current)
64 copyTimer.current = setTimeout(() => setCopied(null), 1500)
65 }
66
67 /** Archives are built on demand; the short-lived link is clicked straight away. */
68 async function download(format: ArchiveFormat) {
69 if (busy || !rev) return
70 setBusy(format)
71 setDownloadFailed(false)
72 try {
73 const result = await createArchive(space, repo, { revision: rev, format })
74 if (!result.download_url) throw new Error('archive url missing')
75 const link = document.createElement('a')
76 link.href = result.download_url
77 link.rel = 'noopener'
78 link.click()
79 } catch {
80 setDownloadFailed(true)
81 } finally {
82 setBusy(null)
83 }
84 }
85
86 return (
87 <div ref={root} className="relative flex items-center">
88 <button
89 type="button"
90 aria-haspopup="dialog"
91 aria-expanded={open}
92 aria-label={t('repo.clone.title')}
93 onClick={() => setOpen(value => !value)}
94 className="flex h-9 shrink-0 items-center gap-1.5 rounded-[9px] border border-shell-hairline bg-shell-field px-2.5 text-[13px] font-medium transition-colors hover:brightness-95"
95 >
96 <GitFork className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
97 <span className="whitespace-nowrap">{t('repo.clone.button')}</span>
98 <ChevronDown className="h-3 w-3 shrink-0 text-shell-ink-muted" aria-hidden />
99 </button>
100 {open ? (
101 <div
102 role="dialog"
103 aria-label={t('repo.clone.title')}
104 className="absolute right-0 top-full z-40 mt-2 w-[420px] max-w-[calc(100vw-2rem)] rounded-lg border border-shell-hairline bg-shell-surface p-3 shadow-xl"
105 >
106 {ordered.length === 0 ? (
107 <p className="text-[12.5px] text-shell-ink-muted" title={t('repo.clone.notConfiguredHint')}>
108 {t('repo.clone.notConfigured')}
109 </p>
110 ) : (
111 <div className="space-y-3">
112 {ordered.map(target => (
113 <div key={target.protocol}>
114 <p className="mb-1.5 text-[12.5px] font-semibold">
115 {target.protocol === 'ssh' ? t('repo.clone.withSsh') : t('repo.clone.withHttps')}
116 </p>
117 <div className="flex items-center gap-2 rounded-lg border border-shell-hairline bg-shell-field px-2.5 py-1.5">
118 <span className="min-w-0 flex-1 truncate font-mono text-[12.5px]" title={target.url}>
119 {target.url}
120 </span>
121 <button
122 type="button"
123 aria-label={copied === target.protocol ? t('repo.clone.copiedLabel') : t('repo.clone.copy')}
124 title={target.protocol === 'ssh' ? t('repo.clone.sshHint') : t('repo.clone.copy')}
125 onClick={() => void copy(target)}
126 className="flex h-7 shrink-0 items-center gap-1 rounded-md px-2 text-[12px] transition-colors hover:bg-shell-active hover:text-shell-ink"
127 >
128 {copied === target.protocol ? (
129 <Check className="h-3.5 w-3.5" aria-hidden />
130 ) : (
131 <Copy className="h-3.5 w-3.5" aria-hidden />
132 )}
133 {copied === target.protocol ? t('common.action.copied') : t('common.action.copy')}
134 </button>
135 </div>
136 </div>
137 ))}
138 </div>
139 )}
140 <div className="mt-3 border-t border-shell-hairline pt-3">
141 <p className="mb-1.5 text-[12.5px] font-semibold">{t('repo.clone.download')}</p>
142 <div className="flex overflow-hidden rounded-lg border border-shell-hairline">
143 {ARCHIVE_FORMATS.map((format, index) => (
144 <button
145 key={format.value}
146 type="button"
147 disabled={busy !== null || !rev}
148 onClick={() => void download(format.value)}
149 className={`flex h-8 flex-1 items-center justify-center gap-1 text-[12.5px] font-medium transition-colors hover:bg-shell-active disabled:opacity-60 ${
150 index > 0 ? 'border-l border-shell-hairline' : ''
151 }`}
152 >
153 {busy === format.value ? <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden /> : null}
154 {format.label}
155 </button>
156 ))}
157 </div>
158 {downloadFailed ? (
159 <p className="mt-1.5 text-[12px] text-shell-ink-muted" role="alert">{t('repo.clone.downloadFailed')}</p>
160 ) : null}
161 </div>
162 </div>
163 ) : null}
164 </div>
165 )
166}
Modified web/src/pages/workspace/ui/repo/repo-aside.tsx web/src/pages/workspace/ui/repo/repo-aside.tsx +3 −3 View web/src/pages/workspace/ui/repo/repo-aside.tsx
@@ -60,7 +60,7 @@
6060
6161 return (
6262 <div className="repo-overview space-y-4">
63 <section className="repo-about-card rounded-xl border border-shell-hairline bg-shell-surface p-5">
63 <section className="repo-about-card rounded-xl bg-shell-surface p-5">
6464 <h3 className="mb-3 text-[14px] font-semibold">
6565 {t('shell.repo.overview')}
6666 </h3>
@@ -112,14 +112,14 @@
112112 {starError ? <p className="mt-2 text-[12px] text-shell-ink-muted" role="alert">{starError}</p> : null}
113113 </section>
114114 {languagesHtml ? (
115 <section className="rounded-xl border border-shell-hairline bg-shell-surface p-5">
115 <section className="rounded-xl bg-shell-surface p-5">
116116 <h3 className="mb-3 text-[14px] font-semibold">
117117 {t('shell.repo.languages')}
118118 </h3>
119119 <div className="repo-embed" dangerouslySetInnerHTML={{ __html: languagesHtml }} />
120120 </section>
121121 ) : repo.primary_language ? (
122 <section className="rounded-xl border border-shell-hairline bg-shell-surface p-5 text-[13px]">
122 <section className="rounded-xl bg-shell-surface p-5 text-[13px]">
123123 {t('shell.repo.primaryLanguage', { language: repo.primary_language ?? '' })}
124124 </section>
125125 ) : null}
Modified web/src/pages/workspace/ui/repo/repo-polish.css web/src/pages/workspace/ui/repo/repo-polish.css +3 −24 View web/src/pages/workspace/ui/repo/repo-polish.css
@@ -1,21 +1,14 @@
1/* Repository pages use compact technical detail inside calm, readable surfaces. */
1
22.repo-view-content { min-height: 0; padding-top: 4px; }
33.repo-toolbar { background: var(--shell-surface); }
44.repo-toolbar-refs > span,
55.repo-toolbar-refs > .repo-refs > button { min-height: 36px; border: 1px solid var(--shell-hairline); border-radius: 9px; background: var(--shell-field); }
66.repo-toolbar-tabs nav { box-shadow: 0 1px 2px rgb(24 28 31 / 2%); }
77.repo-toolbar-tabs button, .repo-toolbar-tabs span { transition: color 160ms, background 160ms; }
8/* The toolbar stays one row while the ref chips, the tab strip and the overview
9 toggle still fit together; once the chips take a line of their own the toggle
10 stays glued to the right end of the tab line. The strip takes the rest of the
11 width but never the toggle's. Scoped through .repo-toolbar so these win over
12 the shell defaults, which load first. */
8
139.repo-toolbar .repo-toolbar-tabs { flex: 1 1 20rem; min-width: 20rem; max-width: calc(100% - 3rem); }
1410.repo-toolbar-tabs > nav { flex: 0 0 auto; }
15/* Labels that do not fit move into the "…" menu, so the strip no longer
16 scrolls — and it must not clip that menu, which hangs below the toolbar. */
1711.repo-toolbar .repo-toolbar-tabs { overflow: visible; }
18/* The "…" trigger takes exactly the width the tab measurement reserves for it. */
1912.repo-tabs-more { display: flex; height: 100%; width: 1.75rem; flex-shrink: 0; align-items: center; justify-content: center; border-radius: 8px; color: var(--shell-ink-muted); transition: color 160ms, background 160ms; }
2013.repo-tabs-more:hover, .repo-tabs-more[aria-expanded='true'] { color: var(--shell-ink); background: var(--shell-surface); }
2114.repo-tabs-menu button { min-height: 32px; border-radius: 7px; }
@@ -27,22 +20,15 @@
2720@media (max-width: 480px) {
2821 .repo-toolbar .repo-toolbar-tabs { min-width: 12rem; }
2922}
30.repo-clone { min-height: 36px; border: 1px solid var(--shell-hairline); border-radius: 9px; background: var(--shell-field); }
3123.repo-content-layout [data-repo-scroll] { padding-right: 2px; }
3224.repo-section-toolbar { min-height: 42px; }
3325.repo-section-toolbar h3 { letter-spacing: -.01em; }
3426.repo-file-toolbar { min-height: 44px; padding: 10px 12px; border: 1px solid var(--shell-hairline); border-radius: 12px; background: var(--shell-surface); }
3527.repo-code-view { display: flex; flex-direction: column; gap: 12px; }
36.repo-code-header { padding: 6px 8px; border: 1px solid var(--shell-hairline); border-radius: 12px; background: var(--shell-surface); box-shadow: 0 1px 2px rgb(24 28 31 / 2%); }
28.repo-code-header { padding: 6px 8px; border-radius: 12px; background: var(--shell-surface); box-shadow: 0 1px 2px rgb(24 28 31 / 2%); }
3729.repo-code-header-path { min-width: 0; flex: 1 1 110px; overflow: hidden; white-space: nowrap; }
3830.repo-code-header-path.repo-breadcrumbs { flex-wrap: nowrap; }
3931.repo-code-header-path button { white-space: nowrap; }
40/* The breadcrumb and the clone box share the first row while the column can
41 hold both; the clone box drops to a row of its own only when it cannot join
42 without squeezing the path into a sliver. Everything grows, so whichever row
43 it lands on is filled to the edge instead of leaving a gap. */
44.repo-code-header-clone { flex: 1 1 240px; min-width: 168px; }
45.repo-code-header-clone > .repo-clone { width: 100%; min-width: 0; max-width: 100%; }
4632.repo-directory-header { padding: 9px 12px; border-bottom: 1px solid var(--shell-hairline); background: color-mix(in srgb, var(--shell-field) 45%, var(--shell-surface)); }
4733.repo-tree-card .repo-embed { border-radius: 0; }
4834.repo-breadcrumbs button { border-radius: 6px; padding: 3px 5px; transition: color 160ms, background 160ms; }
@@ -55,9 +41,6 @@
5541.repo-file-table button { border-radius: 6px; color: var(--shell-ink); }
5642.repo-file-table button:hover { color: var(--shell-ink); text-decoration: underline; text-underline-offset: 3px; }
5743.repo-readme, .repo-commit-summary, .repo-diff-card, .repo-settings-content, .repo-about-card { box-shadow: 0 1px 2px rgb(24 28 31 / 2%); }
58/* The PDF reader gets its own scrollport and a page-sized box: the server
59 rasterises each page, so the surface has to stay dark behind the white
60 paper instead of inheriting the shell background. */
6144.repo-pdf-toolbar { display: flex; align-items: center; gap: 4px; padding: 6px 8px; border: 1px solid var(--shell-hairline); border-bottom: 0; border-radius: 12px 12px 0 0; background: var(--shell-surface); }
6245.repo-pdf-step { display: flex; height: 30px; min-width: 30px; align-items: center; justify-content: center; border-radius: 7px; color: var(--shell-ink-muted); transition: color 160ms, background 160ms; }
6346.repo-pdf-step:hover:not(:disabled) { color: var(--shell-ink); background: var(--shell-field); }
@@ -96,15 +79,11 @@
9679
9780@media (max-width: 760px) {
9881 .repo-view-content { padding-top: 0; }
99 /* Rows stack: the actions take their own line instead of squeezing the
100 subject and meta down to a one-character column. */
10182 .repo-commit-row, .repo-branch-row { flex-wrap: wrap; row-gap: 8px; }
10283 .repo-toolbar-tabs button, .repo-toolbar-tabs span { padding-inline: 10px; }
103 .repo-clone { width: 100%; }
10484 .repo-file-toolbar { padding: 8px 10px; }
10585 .repo-directory-header { padding: 8px 10px; }
10686 .repo-code-header { padding: 8px; }
107 .repo-code-header-clone > .repo-clone { width: 100%; }
10887 .repo-file-table th, .repo-file-table td { padding-left: 12px; padding-right: 12px; }
10988 .repo-commit-row { padding: 14px; }
11089 .repo-commit-actions { width: auto; flex: 1 1 100%; margin-left: 0; justify-content: flex-end; }
Modified web/src/pages/workspace/ui/repo/repo-view.tsx web/src/pages/workspace/ui/repo/repo-view.tsx +15 −91 View web/src/pages/workspace/ui/repo/repo-view.tsx
@@ -1,12 +1,11 @@
11import { useEffect, useMemo, useRef, useState } from 'react'
2import { ArchiveRestore, Check, Copy } from 'lucide-react'
2import { ArchiveRestore } from 'lucide-react'
33import { RepoLayout } from '../layout/repo-layout'
44import {
55 cloneTargets,
66 fetchHtmlFragment,
77 fetchRepo,
88 htmlFragmentUrl,
9 type CloneTarget,
109} from '@/entities/repo'
1110import { useT } from '@/shared/i18n'
1211import { useAsync, repoKey } from '@/shared/lib'
@@ -20,6 +19,7 @@
2019import { AiSummaryTab } from '@/features/repo-ai'
2120import { ReleasesTab } from '@/features/repo-releases'
2221import { RefPicker } from './ref-picker'
22import { RepoCloneMenu } from './clone-menu'
2323import { RepoAside } from './repo-aside'
2424import './repo-polish.css'
2525
@@ -145,16 +145,19 @@
145145 title={`${spaceName} / ${repo.name}`}
146146 topic={repo.description ?? undefined}
147147 refsSlot={
148 <RefPicker
149 space={spaceName}
150 repo={repoName}
151 rev={rev}
152 onRevChange={setRev}
153 branchesCount={repo.branches_count ?? 0}
154 tagsCount={repo.tags_count ?? 0}
155 commitsCount={repo.commits_count ?? 0}
156 onShowCommits={() => setTab('commits')}
157 />
148 <>
149 <RefPicker
150 space={spaceName}
151 repo={repoName}
152 rev={rev}
153 onRevChange={setRev}
154 branchesCount={repo.branches_count ?? 0}
155 tagsCount={repo.tags_count ?? 0}
156 commitsCount={repo.commits_count ?? 0}
157 onShowCommits={() => setTab('commits')}
158 />
159 <RepoCloneMenu targets={clones} space={spaceName} repo={repoName} rev={rev || repo.default_branch || ''} />
160 </>
158161 }
159162 activeTab={tab}
160163 onTabChange={setTab}
@@ -192,7 +195,6 @@
192195 closeFile={closeFile}
193196 onOpenCommit={sha => patch({ tab: 'commits', commit: sha })}
194197 onShowHistory={showHistory}
195 cloneSlot={<CloneBox targets={clones} fill />}
196198 />
197199 ) : null}
198200 {tab === 'commits' && rev ? (
@@ -287,84 +289,6 @@
287289 ) : null}
288290 </div>
289291 </RepoLayout>
290 )
291}
292
293function CloneBox({ targets, fill = false }: { targets: CloneTarget[]; fill?: boolean }) {
294 const { t } = useT()
295 const [protocol, setProtocol] = useState<CloneTarget['protocol'] | null>(null)
296 const [copied, setCopied] = useState(false)
297 const [copyError, setCopyError] = useState(false)
298 // Derived: a stale selection (repo switch, protocol not offered) falls back
299 // to the first address the backend provides.
300 const active = targets.find(t => t.protocol === protocol) ?? targets[0] ?? null
301
302 useEffect(() => {
303 if (!copied) return
304 const timer = setTimeout(() => setCopied(false), 1500)
305 return () => clearTimeout(timer)
306 }, [copied])
307
308 if (!active) {
309 return (
310 <span
311 className="flex h-9 items-center rounded-md bg-shell-field px-3 text-[12px] text-shell-ink-muted"
312 title={t('shell.repo.cloneUnconfigured')}
313 >
314 {t('shell.repo.cloneUnconfiguredShort')}
315 </span>
316 )
317 }
318
319 return (
320 <span
321 className={`repo-clone flex items-center gap-1.5 rounded-md bg-shell-field px-1.5 ${fill ? 'w-auto min-w-0 max-w-full' : 'w-[300px] min-w-[170px] xl:w-[360px]'}`}
322 >
323 {targets.length > 1 ? (
324 <span className="flex shrink-0 items-center gap-0.5">
325 {targets.map(target => (
326 <button
327 key={target.protocol}
328 type="button"
329 title={
330 target.protocol === 'ssh' ? t('shell.repo.sshHint') : undefined
331 }
332 aria-pressed={target.protocol === active.protocol}
333 onClick={() => { setProtocol(target.protocol); setCopied(false); setCopyError(false) }}
334 className={`rounded px-1.5 py-0.5 text-[11px] font-semibold transition-colors ${
335 target.protocol === active.protocol
336 ? 'bg-shell-active text-shell-ink'
337 : 'text-shell-ink-muted hover:text-shell-ink'
338 }`}
339 >
340 {target.label}
341 </button>
342 ))}
343 </span>
344 ) : null}
345 <span className="min-w-0 flex-1 truncate font-mono text-[12px] text-shell-ink-muted" title={active.url}>
346 {active.url}
347 </span>
348 <button
349 type="button"
350 aria-label={copied ? t('shell.repo.cloneCopied') : t('shell.repo.copyClone')}
351 title={copyError ? t('shell.repo.copyFailed') : t('shell.repo.copyClone')}
352 className="flex h-[22px] shrink-0 items-center gap-1 rounded px-1.5 text-[12px] transition-colors hover:bg-shell-active hover:text-shell-ink"
353 onClick={async () => {
354 try {
355 await navigator.clipboard.writeText(active.url)
356 setCopied(true)
357 setCopyError(false)
358 } catch {
359 setCopyError(true)
360 }
361 }}
362 >
363 {copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
364 {copied ? t('common.action.copied') : copyError ? t('shell.repo.copyManually') : t('common.action.copy')}
365 </button>
366 <span className="sr-only" role="status">{copied ? t('shell.repo.clipboardOk') : copyError ? t('shell.repo.clipboardFail') : ''}</span>
367 </span>
368292 )
369293}
370294
Added web/src/pages/workspace/ui/members-view.tsx web/src/pages/workspace/ui/members-view.tsx +225 −0 View web/src/pages/workspace/ui/members-view.tsx
@@ -0,0 +1,225 @@
1import { useState } from 'react'
2import { ArrowDownUp, ChevronLeft, ChevronRight, Crown, RefreshCw, Search, Users } from 'lucide-react'
3import { AppShell, InitialAvatar, ShellTopBar, fmtDate, type AppShellProps } from '@/shared/ui'
4import { spaceAdminMembersKey, useAsync } from '@/shared/lib'
5import { useT } from '@/shared/i18n'
6import { fetchSpace } from '@/features/space-management/model/api'
7import { listSpaceMembers, type SpaceMember } from '@/features/space-management/model/space-admin'
8import { MEMBER_STATUS_LABELS } from '@/features/space-management/ui/space-settings/meta'
9
10export interface MembersViewProps {
11 spaceName: string
12 /** AppShell slots owned by the workspace frame. */
13 shell: Pick<AppShellProps, 'rail' | 'sideHeader' | 'navPinned' | 'nav' | 'sideFooter'>
14}
15
16const PAGE_SIZES = [25, 50, 100]
17
18/** Full-page community roster, opened from the member count in the space header. */
19export function MembersView({ spaceName, shell }: MembersViewProps) {
20 const { t } = useT()
21 const [tick, setTick] = useState(0)
22 const [query, setQuery] = useState('')
23 const [sort, setSort] = useState<'joined' | 'name'>('joined')
24 const [pageSize, setPageSize] = useState(PAGE_SIZES[0])
25 const [page, setPage] = useState(0)
26
27 const members = useAsync(() => listSpaceMembers(spaceName), [spaceName, tick], {
28 cacheKey: spaceAdminMembersKey(spaceName),
29 })
30 const space = useAsync(() => fetchSpace(spaceName), [spaceName, tick])
31 const ownerId =
32 space.data?.owner?.user_id ??
33 (space.data as unknown as { owner_uid?: string } | undefined)?.owner_uid
34
35 const all = members.data ?? []
36 const needle = query.trim().toLowerCase()
37 const filtered = all.filter(member => {
38 if (!needle) return true
39 const user = member.user
40 return (
41 (user?.username ?? '').toLowerCase().includes(needle) ||
42 (user?.display_name ?? '').toLowerCase().includes(needle) ||
43 (member.alias_name ?? '').toLowerCase().includes(needle)
44 )
45 })
46 const sorted = [...filtered].sort((a, b) =>
47 sort === 'name'
48 ? memberName(a).localeCompare(memberName(b))
49 : (b.joined_at ?? '').localeCompare(a.joined_at ?? ''),
50 )
51 const pageCount = Math.max(1, Math.ceil(sorted.length / pageSize))
52 const current = Math.min(page, pageCount - 1)
53 const rows = sorted.slice(current * pageSize, current * pageSize + pageSize)
54
55 return (
56 <AppShell {...shell}>
57 <ShellTopBar
58 icon={<Users className="h-[18px] w-[18px] shrink-0 text-shell-ink-muted" />}
59 title={t('space.members.title')}
60 topic={t('space.members.sub')}
61 actions={
62 <button
63 type="button"
64 title={t('space.members.refresh')}
65 aria-label={t('space.members.refresh')}
66 onClick={() => setTick(value => value + 1)}
67 className="workspace-icon-button"
68 >
69 <RefreshCw className="h-4 w-4" aria-hidden />
70 </button>
71 }
72 />
73 <div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto bg-shell-field p-6">
74 <div className="flex flex-wrap items-center gap-3">
75 <div className="min-w-0 flex-1">
76 <p className="text-[15px] font-semibold">{t('space.members.recent')}</p>
77 <p className="mt-0.5 text-[12.5px] text-shell-ink-muted">
78 {t('space.members.shown', { shown: rows.length, total: sorted.length })}
79 </p>
80 </div>
81 <label className="flex h-9 items-center gap-2 rounded-lg border border-shell-hairline bg-shell-field px-2.5">
82 <Search className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
83 <input
84 value={query}
85 onChange={event => {
86 setQuery(event.target.value)
87 setPage(0)
88 }}
89 placeholder={t('space.members.searchPlaceholder')}
90 aria-label={t('space.members.searchAria')}
91 className="w-[200px] bg-transparent text-[13px] outline-none"
92 />
93 </label>
94 <label className="flex h-9 items-center gap-2 rounded-lg border border-shell-hairline bg-shell-field px-2.5 text-[13px]">
95 <ArrowDownUp className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
96 <span className="text-shell-ink-muted">{t('space.members.sort')}</span>
97 <select
98 value={sort}
99 onChange={event => {
100 setSort(event.target.value as 'joined' | 'name')
101 setPage(0)
102 }}
103 aria-label={t('space.members.sort')}
104 className="bg-transparent outline-none"
105 >
106 <option value="joined">{t('space.members.sortJoined')}</option>
107 <option value="name">{t('space.members.sortName')}</option>
108 </select>
109 </label>
110 </div>
111
112 <div className="overflow-hidden rounded-xl bg-shell-surface">
113 <table className="w-full border-collapse text-[13px]">
114 <thead>
115 <tr className="text-left text-[12px] text-shell-ink-muted">
116 <th className="px-4 py-2.5 font-semibold">{t('space.members.col.name')}</th>
117 <th className="px-4 py-2.5 font-semibold">{t('space.members.col.joined')}</th>
118 <th className="px-4 py-2.5 font-semibold">{t('space.members.col.method')}</th>
119 <th className="px-4 py-2.5 font-semibold">{t('space.members.col.status')}</th>
120 </tr>
121 </thead>
122 <tbody>
123 {rows.map(member => {
124 const name = memberName(member)
125 const isOwner = Boolean(ownerId && member.user?.user_id === ownerId)
126 return (
127 <tr
128 key={member.user?.user_id ?? member.created_at}
129 className="border-t border-shell-hairline/70 transition-colors hover:bg-shell-field/70"
130 >
131 <td className="px-4 py-2.5">
132 <span className="flex min-w-0 items-center gap-2.5">
133 <InitialAvatar name={name} url={member.alias_avatar_url || member.user?.avatar_url} size={32} />
134 <span className="min-w-0">
135 <span className="flex min-w-0 items-center gap-1.5">
136 <span className="truncate font-medium">{name}</span>
137 {isOwner ? <Crown className="h-3.5 w-3.5 shrink-0 text-[#f0b232]" aria-hidden /> : null}
138 </span>
139 <span className="block truncate text-[12px] text-shell-ink-muted">
140 @{member.user?.username ?? member.user?.user_id}
141 </span>
142 </span>
143 </span>
144 </td>
145 <td className="px-4 py-2.5 text-shell-ink-muted">{fmtDate(member.joined_at)}</td>
146 <td className="px-4 py-2.5 text-shell-ink-muted">
147 {member.invited_by
148 ? t('space.members.invitedBy', {
149 name: member.invited_by.display_name || member.invited_by.username,
150 })
151 : isOwner
152 ? t('space.members.owner')
153 : t('space.members.joinedSelf')}
154 </td>
155 <td className="px-4 py-2.5">
156 <span className="rounded-full bg-shell-field px-2 py-0.5 text-[11.5px] text-shell-ink-muted">
157 {MEMBER_STATUS_LABELS[member.status] ?? member.status}
158 </span>
159 </td>
160 </tr>
161 )
162 })}
163 {rows.length === 0 ? (
164 <tr>
165 <td colSpan={4} className="px-4 py-12 text-center text-[13px] text-shell-ink-muted">
166 {t('space.members.noMatch')}
167 </td>
168 </tr>
169 ) : null}
170 </tbody>
171 </table>
172 </div>
173
174 <div className="flex flex-wrap items-center justify-between gap-3">
175 <label className="flex items-center gap-2 text-[12.5px] text-shell-ink-muted">
176 {t('space.members.rowsPerPage')}
177 <select
178 value={pageSize}
179 onChange={event => {
180 setPageSize(Number(event.target.value))
181 setPage(0)
182 }}
183 className="h-8 rounded-lg border border-shell-hairline bg-shell-field px-2 text-[13px] text-shell-ink"
184 >
185 {PAGE_SIZES.map(size => (
186 <option key={size} value={size}>
187 {size}
188 </option>
189 ))}
190 </select>
191 </label>
192 {pageCount > 1 ? (
193 <div className="flex items-center gap-2 text-[12.5px] text-shell-ink-muted">
194 <button
195 type="button"
196 aria-label="previous"
197 disabled={current === 0}
198 onClick={() => setPage(Math.max(0, current - 1))}
199 className="workspace-icon-button is-compact"
200 >
201 <ChevronLeft className="h-4 w-4" aria-hidden />
202 </button>
203 <span>
204 {current + 1} / {pageCount}
205 </span>
206 <button
207 type="button"
208 aria-label="next"
209 disabled={current >= pageCount - 1}
210 onClick={() => setPage(Math.min(pageCount - 1, current + 1))}
211 className="workspace-icon-button is-compact"
212 >
213 <ChevronRight className="h-4 w-4" aria-hidden />
214 </button>
215 </div>
216 ) : null}
217 </div>
218 </div>
219 </AppShell>
220 )
221}
222
223function memberName(member: SpaceMember): string {
224 return member.alias_name || member.user?.display_name || member.user?.username || member.user?.user_id || '—'
225}
Modified web/src/pages/workspace/ui/sidebar-nav.tsx web/src/pages/workspace/ui/sidebar-nav.tsx +1 −1 View web/src/pages/workspace/ui/sidebar-nav.tsx
@@ -600,7 +600,7 @@
600600 : drop === 'after'
601601 ? 'border-b-2 border-shell-ink-muted'
602602 : ''
603 const shared = `workspace-nav-row flex h-10 items-center gap-1.5 rounded-md pr-2 text-[14px] font-medium ${
603 const shared = `workspace-nav-row flex h-8 items-center gap-1.5 rounded-md pr-2 text-[14px] font-medium ${
604604 dragging ? 'opacity-40 ' : ''
605605 }${highlight}`
606606 const indent = { paddingLeft: 8 + depth * 14 }
Modified web/src/pages/workspace/ui/space-rail.tsx web/src/pages/workspace/ui/space-rail.tsx +56 −16 View web/src/pages/workspace/ui/space-rail.tsx
@@ -1,6 +1,6 @@
11import { useEffect, useRef, type ReactNode } from 'react'
22import { Link } from 'react-router-dom'
3import { Compass, Layers3, Plus } from 'lucide-react'
3import { Layers3, Plus } from 'lucide-react'
44import { useT } from '@/shared/i18n'
55import { initial } from '../model/initial'
66import { prefetchSpaceLists } from '../model/prefetch'
@@ -23,7 +23,7 @@
2323 const { t } = useT()
2424 return (
2525 <>
26 <RailLink to="/app/@me" active={!activeName && !discovering} label={t('shell.rail.home')} square>
26 <RailLink to="/app/@me" active={!activeName && !discovering} label={t('shell.rail.home')}>
2727 <Layers3 className="h-[26px] w-[26px]" />
2828 </RailLink>
2929 <div className="h-0.5 w-8 rounded-full bg-shell-tile" />
@@ -51,12 +51,12 @@
5151 <Plus className="h-[22px] w-[22px]" />
5252 </RailButton>
5353 ) : (
54 <RailLink to="/app/explore" active={false} label={t('shell.rail.addSpace')}>
54 <RailLink to="/app/explore" active={false} label={t('shell.rail.addSpace')} dashed>
5555 <Plus className="h-[22px] w-[22px]" />
5656 </RailLink>
5757 )}
58 <RailLink to="/app/explore" active={discovering} label={t('shell.rail.discover')}>
59 <Compass className="h-[22px] w-[22px]" />
58 <RailLink to="/app/explore" active={discovering} label={t('shell.rail.discover')} dashed>
59 <CompassFilled className="h-[18px] w-[18px]" />
6060 </RailLink>
6161 </>
6262 )
@@ -78,9 +78,10 @@
7878 title={label}
7979 aria-label={label}
8080 onClick={onClick}
81 className="flex h-12 w-12 items-center justify-center overflow-hidden rounded-xl bg-shell-tile text-shell-ink-muted transition-colors hover:rounded-lg hover:bg-shell-tile hover:text-shell-ink"
81 className="group relative flex h-12 w-12 items-center justify-center overflow-hidden bg-transparent text-shell-ink transition-colors"
8282 >
83 {children}
83 <RailDashedRing />
84 <span className="relative flex items-center justify-center">{children}</span>
8485 </button>
8586 </div>
8687 )
@@ -90,14 +91,15 @@
9091 to,
9192 active,
9293 label,
93 square = false,
94 dashed = false,
9495 onHover,
9596 children,
9697}: {
9798 to: string
9899 active: boolean
99100 label: string
100 square?: boolean
101 /** Set the create / discover tiles apart with a dashed ring until they are used. */
102 dashed?: boolean
101103 /** Hover prefetch, delayed so a cursor sweeping the rail fires nothing. */
102104 onHover?: () => void
103105 children: ReactNode
@@ -129,16 +131,54 @@
129131 aria-label={label}
130132 aria-current={active ? 'page' : undefined}
131133 {...hover}
132 className={`flex h-12 w-12 items-center justify-center overflow-hidden transition-colors ${
133 square
134 ? 'rounded-xl bg-shell-surface text-shell-ink ring-1 ring-shell-hairline'
135 : active
136 ? 'rounded-full bg-shell-surface font-semibold text-shell-ink'
137 : 'rounded-xl bg-shell-tile text-shell-ink-muted hover:rounded-lg hover:bg-shell-tile hover:text-shell-ink'
134 className={`group relative flex h-12 w-12 items-center justify-center overflow-hidden transition-colors ${
135 active
136 ? 'bg-shell-tile font-semibold text-shell-ink'
137 : dashed
138 ? 'bg-transparent text-shell-ink'
139 : 'bg-shell-tile text-shell-ink-muted hover:text-shell-ink'
138140 }`}
139141 >
140 {children}
142 {dashed ? <RailDashedRing /> : null}
143 <span className="relative flex items-center justify-center">{children}</span>
141144 </Link>
142145 </div>
143146 )
144147}
148
149/** Dashed rail ring: long, sparse segments that square off with their tile. */
150function RailDashedRing() {
151 return (
152 <svg
153 aria-hidden
154 viewBox="0 0 48 48"
155 className="pointer-events-none absolute inset-0 h-full w-full text-shell-tile"
156 >
157 <rect
158 className="rail-dashed-ring-shape"
159 x="2"
160 y="2"
161 width="44"
162 height="44"
163 fill="none"
164 stroke="currentColor"
165 strokeWidth="2"
166 strokeDasharray="6 8"
167 />
168 </svg>
169 )
170}
171
172/** Filled compass: solid disc with the needle knocked out, matching the rail reference. */
173function CompassFilled({ className }: { className?: string }) {
174 return (
175 <svg viewBox="0 0 24 24" className={className} aria-hidden>
176 <path
177 fillRule="evenodd"
178 clipRule="evenodd"
179 className="fill-current"
180 d="M12 1.5a10.5 10.5 0 1 0 0 21 10.5 10.5 0 0 0 0-21Zm4.8 5.7-4.2 6.6-6.6 4.2 4.2-6.6 6.6-4.2Z"
181 />
182 </svg>
183 )
184}
Modified web/src/pages/workspace/ui/user-panel.tsx web/src/pages/workspace/ui/user-panel.tsx +132 −21 View web/src/pages/workspace/ui/user-panel.tsx
@@ -1,4 +1,5 @@
1import { HeadphoneOff, Headphones, Mic, MicOff, Settings } from 'lucide-react'
1import { useEffect, useRef, useState } from 'react'
2import { Check, Copy, HeadphoneOff, Headphones, Mic, MicOff, Pencil, Settings } from 'lucide-react'
23import { useT } from '@/shared/i18n'
34import { useVoiceSession, voiceSession } from '@/shared/voice'
45import { initial } from '../model/initial'
@@ -8,6 +9,10 @@
89export function UserPanel({ me, onSettings }: { me: Me; onSettings?: () => void }) {
910 const { t } = useT()
1011 const session = useVoiceSession()
12 const [cardOpen, setCardOpen] = useState(false)
13 const [copied, setCopied] = useState(false)
14 const root = useRef<HTMLDivElement>(null)
15 const copyTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
1116 const inCall = session.roomId !== null && session.status !== 'idle'
1217 // In a call the two toggles drive the live tracks; outside one the microphone
1318 // button holds the state the next join starts from and the deafen flag waits
@@ -19,27 +24,133 @@
1924 : micOn
2025 ? t('shell.user.mute')
2126 : t('shell.user.unmute')
27
28 useEffect(() => () => clearTimeout(copyTimer.current), [])
29
30 useEffect(() => {
31 if (!cardOpen) return
32 const onDown = (event: PointerEvent) => {
33 if (!root.current?.contains(event.target as Node)) setCardOpen(false)
34 }
35 const onKey = (event: KeyboardEvent) => {
36 if (event.key === 'Escape') setCardOpen(false)
37 }
38 window.addEventListener('pointerdown', onDown)
39 window.addEventListener('keydown', onKey)
40 return () => {
41 window.removeEventListener('pointerdown', onDown)
42 window.removeEventListener('keydown', onKey)
43 }
44 }, [cardOpen])
45
46 const name = me.user.display_name || me.user.username
47
48 async function copyText(value: string) {
49 try {
50 await navigator.clipboard.writeText(value)
51 } catch {
52 // Clipboard access can be denied; the label still confirms the attempt.
53 }
54 setCopied(true)
55 clearTimeout(copyTimer.current)
56 copyTimer.current = setTimeout(() => setCopied(false), 1600)
57 }
58
2259 return (
23 <>
24 {me.user.avatar_url ? (
25 <img
26 src={me.user.avatar_url}
27 alt=""
28 className="h-9 w-9 shrink-0 rounded-full bg-shell-tile object-cover"
29 />
30 ) : (
31 <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-shell-tile text-sm font-semibold text-shell-ink">
32 {initial(me.user.display_name || me.user.username)}
33 </div>
34 )}
35 <div className="min-w-0 flex-1">
36 <div className="truncate text-[14px] font-semibold leading-tight">
37 {me.user.display_name || me.user.username}
38 </div>
39 <div className="truncate text-[12px] leading-tight text-shell-ink-muted">
40 @{me.user.username}
60 <div ref={root} className="relative flex min-w-0 flex-1 items-center gap-2.5">
61 {cardOpen ? (
62 <div
63 role="dialog"
64 aria-label={t('shell.user.card')}
65 className="absolute -left-4 -right-4 bottom-full z-40 mb-3 overflow-hidden rounded-xl bg-shell-surface shadow-[0_16px_44px_rgb(24_28_31_/_16%)]"
66 >
67 <div className="h-[68px] bg-[linear-gradient(135deg,var(--shell-action),color-mix(in_srgb,var(--shell-action)_45%,var(--shell-tile)))]" />
68 <div className="px-4 pb-4">
69 <span className="relative -mt-8 mb-2 block h-16 w-16">
70 {me.user.avatar_url ? (
71 <img
72 src={me.user.avatar_url}
73 alt=""
74 className="h-16 w-16 rounded-full border-[3px] border-shell-surface bg-shell-tile object-cover"
75 />
76 ) : (
77 <span className="flex h-16 w-16 items-center justify-center rounded-full border-[3px] border-shell-surface bg-shell-tile text-xl font-semibold text-shell-ink">
78 {initial(name)}
79 </span>
80 )}
81 <span className="absolute -bottom-0.5 -right-0.5 h-4 w-4 rounded-full border-[3px] border-shell-surface bg-[#23a55a]" />
82 </span>
83 <p className="truncate text-[17px] font-semibold leading-tight">{name}</p>
84 <span className="mt-0.5 flex min-w-0 items-center gap-1">
85 <span className="truncate text-[13px] text-shell-ink-muted">@{me.user.username}</span>
86 <button
87 type="button"
88 title={t('shell.user.copyUsername')}
89 aria-label={t('shell.user.copyUsername')}
90 onClick={() => void copyText(me.user.username)}
91 className="grid h-5 w-5 shrink-0 place-items-center rounded text-shell-ink-muted transition-colors hover:bg-shell-active hover:text-shell-ink"
92 >
93 <Copy className="h-3.5 w-3.5" aria-hidden />
94 </button>
95 </span>
96 <div className="mt-3 overflow-hidden rounded-lg bg-shell-field">
97 <div className="flex items-center gap-2 px-3 py-2 text-[13px]">
98 <span className="h-2.5 w-2.5 shrink-0 rounded-full bg-[#23a55a]" aria-hidden />
99 {t('shell.user.online')}
100 </div>
101 <button
102 type="button"
103 onClick={() => void copyText(me.user.user_id)}
104 className="flex w-full items-center gap-2 px-3 py-2 text-left text-[13px] transition-colors hover:bg-shell-active"
105 >
106 {copied ? (
107 <Check className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
108 ) : (
109 <Copy className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
110 )}
111 {copied ? t('shell.user.copied') : t('shell.user.copyId')}
112 </button>
113 </div>
114 {onSettings ? (
115 <button
116 type="button"
117 onClick={() => {
118 setCardOpen(false)
119 onSettings()
120 }}
121 className="mt-3 flex h-10 w-full items-center justify-center gap-2 rounded-lg bg-primary text-[13px] font-medium text-primary-foreground transition-[filter] hover:brightness-95"
122 >
123 <Pencil className="h-3.5 w-3.5" aria-hidden />
124 {t('shell.user.editProfile')}
125 </button>
126 ) : null}
127 </div>
41128 </div>
42 </div>
129 ) : null}
130 <button
131 type="button"
132 aria-haspopup="dialog"
133 aria-expanded={cardOpen}
134 aria-label={t('shell.user.openCard')}
135 onClick={() => setCardOpen(value => !value)}
136 className="flex min-w-0 flex-1 items-center gap-2.5 rounded-lg px-1 py-1 text-left transition-colors hover:bg-shell-active"
137 >
138 {me.user.avatar_url ? (
139 <img
140 src={me.user.avatar_url}
141 alt=""
142 className="h-9 w-9 shrink-0 rounded-full bg-shell-tile object-cover"
143 />
144 ) : (
145 <span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-shell-tile text-sm font-semibold text-shell-ink">
146 {initial(name)}
147 </span>
148 )}
149 <span className="min-w-0 flex-1">
150 <span className="block truncate text-[14px] font-semibold leading-tight">{name}</span>
151 <span className="block truncate text-[12px] leading-tight text-shell-ink-muted">@{me.user.username}</span>
152 </span>
153 </button>
43154 <div className="flex shrink-0 items-center gap-1 text-shell-ink-muted">
44155 <button
45156 type="button"
@@ -82,6 +193,6 @@
82193 <Settings className="h-4 w-4" />
83194 </button>
84195 </div>
85 </>
196 </div>
86197 )
87198}
Modified web/src/pages/workspace/ui/workspace-shell.tsx web/src/pages/workspace/ui/workspace-shell.tsx +16 −0 View web/src/pages/workspace/ui/workspace-shell.tsx
@@ -6,6 +6,7 @@
66import { ChatView } from './chat/chat-view'
77import { VoiceCallBar } from '@/features/chat'
88import { RepoView } from './repo/repo-view'
9import { MembersView } from './members-view'
910import { useT } from '@/shared/i18n'
1011import { AppShell } from '@/shared/ui'
1112import { ApiFailure } from '@/shared/lib'
@@ -328,6 +329,21 @@
328329 )
329330 }
330331
332 if (mode === 'members') {
333 return (
334 <>
335 <MembersView spaceName={spaceName} shell={shell} />
336 {createSpaceModal}
337 {settingsModal}
338 {spaceSettingsModal}
339 <NotifyToaster />
340
341 <VoiceCallBar />
342 <GlobalSearchCommand scope={searchScope} />
343 </>
344 )
345 }
346
331347 if (roomId) {
332348 return (
333349 <>
Modified web/src/shared/i18n/messages/en/issues.ts web/src/shared/i18n/messages/en/issues.ts +5 −0 View web/src/shared/i18n/messages/en/issues.ts
@@ -184,6 +184,11 @@
184184 'issue.board.activityCount': '{count} entries',
185185 'issue.board.collapse': 'Collapse',
186186 'issue.board.expand': 'Expand',
187 'issue.board.properties': 'Properties',
188 'issue.board.showEarlier': 'Show {count} earlier updates',
189 'issue.board.hideEarlier': 'Hide earlier updates',
190 'issue.board.unassigned': 'Unassigned',
191 'issue.board.send': 'Send',
187192 'issue.board.priorityField': 'Priority',
188193 'issue.board.milestoneField': 'Milestone',
189194 'issue.board.dueField': 'Due date',
Modified web/src/shared/i18n/messages/en/repo.ts web/src/shared/i18n/messages/en/repo.ts +5 −0 View web/src/shared/i18n/messages/en/repo.ts
@@ -37,6 +37,11 @@
3737 'repo.clone.https': 'HTTPS',
3838 'repo.clone.ssh': 'SSH',
3939 'repo.clone.copied': 'Clone URL copied.',
40 'repo.clone.button': 'Clone',
41 'repo.clone.withSsh': 'Clone with SSH',
42 'repo.clone.withHttps': 'Clone with HTTPS',
43 'repo.clone.download': 'Download source code',
44 'repo.clone.downloadFailed': 'Could not build the archive — try again.',
4045 'repo.commit.message': 'Commit message',
4146 'repo.commit.author': 'Author',
4247 'repo.commit.date': 'Date',
Modified web/src/shared/i18n/messages/en/settings.ts web/src/shared/i18n/messages/en/settings.ts +3 −0 View web/src/shared/i18n/messages/en/settings.ts
@@ -2,6 +2,9 @@
22export const settings = {
33 'settings.title': 'User settings',
44 'settings.section.account': 'My account',
5 'settings.group.account': 'Account',
6 'settings.group.preferences': 'Preferences',
7 'settings.group.advanced': 'Advanced',
58 'settings.section.profile': 'Public profile',
69 'settings.section.security': 'Password & security',
710 'settings.section.sessions': 'Devices & sessions',
Modified web/src/shared/i18n/messages/en/shell.ts web/src/shared/i18n/messages/en/shell.ts +7 −0 View web/src/shared/i18n/messages/en/shell.ts
@@ -93,6 +93,13 @@
9393 'shell.user.headphones': 'Headphones',
9494 'shell.user.deafen': 'Deafen',
9595 'shell.user.undeafen': 'Undeafen',
96 'shell.user.card': 'Profile',
97 'shell.user.openCard': 'View profile',
98 'shell.user.online': 'Online',
99 'shell.user.copyUsername': 'Copy username',
100 'shell.user.copyId': 'Copy user ID',
101 'shell.user.copied': 'Copied',
102 'shell.user.editProfile': 'Edit profile',
96103 'shell.chat.unknownUser': 'Unknown user',
97104 'shell.chat.me': 'Me',
98105 'shell.chat.userFallback': 'User {id}',
Modified web/src/shared/i18n/messages/en/space.ts web/src/shared/i18n/messages/en/space.ts +34 −0 View web/src/shared/i18n/messages/en/space.ts
@@ -25,6 +25,23 @@
2525 'space.settings.deleteConfirm': 'Deletion cannot be undone. Type the space name to confirm: {name}',
2626 'space.settings.deleted': 'Space deleted.',
2727 'space.members.title': 'Members',
28 'space.members.sub': 'Browse and search the people in this community.',
29 'space.members.recent': 'Recently joined',
30 'space.members.shown': 'Showing {shown} of {total} members',
31 'space.members.sort': 'Sort',
32 'space.members.sortJoined': 'Recently joined',
33 'space.members.sortName': 'By name',
34 'space.members.rowsPerPage': 'Rows per page',
35 'space.members.col.name': 'Name',
36 'space.members.col.joined': 'Joined',
37 'space.members.col.method': 'Join method',
38 'space.members.col.status': 'Status',
39 'space.members.owner': 'Community owner',
40 'space.members.invitedBy': 'Invited by {name}',
41 'space.members.joinedSelf': 'Joined directly',
42 'space.members.noMatch': 'No members match',
43 'space.members.refresh': 'Refresh members',
44 'space.members.open': 'Open the member list',
2845 'space.members.invite': 'Invite members',
2946 'space.members.remove': 'Remove member',
3047 'space.members.removed': 'Member removed.',
@@ -32,6 +49,18 @@
3249 'space.members.role': 'Role',
3350 'space.members.joinedAt': 'Joined',
3451 'space.roles.title': 'Roles',
52 'space.roles.groupCaption': 'Roles',
53 'space.roles.back': 'Back',
54 'space.roles.editorSub': 'Configure this role and its permissions — saved changes apply right away.',
55 'space.roles.settingsGroup': 'Display',
56 'space.roles.editTitle': 'Edit "{name}"',
57 'space.roles.permissionSearch': 'Search permissions',
58 'space.roles.scopeCommunity': 'Community scope',
59 'space.roles.clearPermissions': 'Clear permissions',
60 'space.roles.colorHint': 'Free-form colour: hex, rgb, hsl or a colour name.',
61 'space.roles.emptyHint': 'No roles yet — create one with “Create role” above.',
62 'space.roles.cancel': 'Cancel',
63 'space.roles.noPermissionMatch': 'No permissions match',
3564 'space.roles.create': 'New role',
3665 'space.roles.name': 'Role name',
3766 'space.roles.permissions': 'Permissions',
@@ -176,8 +205,11 @@
176205 'space.privacy.title': 'Privacy settings',
177206 'space.privacy.description': 'Direct message and mention permissions for your account, across every space.',
178207 'space.privacy.dms': 'Allow direct messages',
208 'space.privacy.dmsHint': 'Let others send you direct messages',
179209 'space.privacy.mentions': 'Allow mentions',
210 'space.privacy.mentionsHint': 'Let others mention you in messages',
180211 'space.privacy.online': 'Show online status',
212 'space.privacy.onlineHint': 'Show your online status in member lists',
181213 'space.privacy.saved': 'Privacy settings saved.',
182214 'space.leave.title': 'Leave space',
183215 'space.leave.sub': 'You will need a new invite to come back to {space}.',
@@ -277,6 +309,8 @@
277309 'space.image.embedSplash': 'Embed background',
278310 'space.image.embedSplashHint': 'Used by external embed pages',
279311 'space.settings.sections.mine': 'My membership',
312 'space.settings.group.community': 'Community',
313 'space.settings.group.members': 'Members & permissions',
280314 'space.settings.sections.general': 'Space profile',
281315 'space.settings.sections.images': 'Icons and banners',
282316 'space.settings.sections.join': 'Join rules',
Modified web/src/shared/i18n/messages/zh-CN/issues.ts web/src/shared/i18n/messages/zh-CN/issues.ts +5 −0 View web/src/shared/i18n/messages/zh-CN/issues.ts
@@ -184,6 +184,11 @@
184184 'issue.board.activityCount': '{count} 条',
185185 'issue.board.collapse': '收起',
186186 'issue.board.expand': '展开',
187 'issue.board.properties': '属性',
188 'issue.board.showEarlier': '显示更早的 {count} 条动态',
189 'issue.board.hideEarlier': '收起更早的动态',
190 'issue.board.unassigned': '未指派',
191 'issue.board.send': '发送',
187192 'issue.board.priorityField': '优先级',
188193 'issue.board.milestoneField': '里程碑',
189194 'issue.board.dueField': '截止日期',
Modified web/src/shared/i18n/messages/zh-CN/repo.ts web/src/shared/i18n/messages/zh-CN/repo.ts +5 −0 View web/src/shared/i18n/messages/zh-CN/repo.ts
@@ -37,6 +37,11 @@
3737 'repo.clone.https': 'HTTPS',
3838 'repo.clone.ssh': 'SSH',
3939 'repo.clone.copied': '克隆地址已复制。',
40 'repo.clone.button': '克隆',
41 'repo.clone.withSsh': '使用 SSH 克隆',
42 'repo.clone.withHttps': '使用 HTTPS 克隆',
43 'repo.clone.download': '下载源代码',
44 'repo.clone.downloadFailed': '打包失败,请重试。',
4045 'repo.commit.message': '提交信息',
4146 'repo.commit.author': '作者',
4247 'repo.commit.date': '日期',
Modified web/src/shared/i18n/messages/zh-CN/settings.ts web/src/shared/i18n/messages/zh-CN/settings.ts +3 −0 View web/src/shared/i18n/messages/zh-CN/settings.ts
@@ -2,6 +2,9 @@
22export const settings = {
33 'settings.title': '用户设置',
44 'settings.section.account': '我的账户',
5 'settings.group.account': '账号',
6 'settings.group.preferences': '偏好',
7 'settings.group.advanced': '高级',
58 'settings.section.profile': '公开资料',
69 'settings.section.security': '密码与安全',
710 'settings.section.sessions': '设备与会话',
Modified web/src/shared/i18n/messages/zh-CN/shell.ts web/src/shared/i18n/messages/zh-CN/shell.ts +7 −0 View web/src/shared/i18n/messages/zh-CN/shell.ts
@@ -93,6 +93,13 @@
9393 'shell.user.headphones': '听筒',
9494 'shell.user.deafen': '关闭听筒',
9595 'shell.user.undeafen': '打开听筒',
96 'shell.user.card': '个人信息',
97 'shell.user.openCard': '查看个人信息',
98 'shell.user.online': '在线',
99 'shell.user.copyUsername': '复制用户名',
100 'shell.user.copyId': '复制用户 ID',
101 'shell.user.copied': '已复制',
102 'shell.user.editProfile': '编辑个人资料',
96103 'shell.chat.unknownUser': '未知用户',
97104 'shell.chat.me': '我',
98105 'shell.chat.userFallback': '用户 {id}',
Modified web/src/shared/i18n/messages/zh-CN/space.ts web/src/shared/i18n/messages/zh-CN/space.ts +34 −0 View web/src/shared/i18n/messages/zh-CN/space.ts
@@ -25,6 +25,23 @@
2525 'space.settings.deleteConfirm': '删除后无法恢复,请输入空间名称确认:{name}',
2626 'space.settings.deleted': '空间已删除。',
2727 'space.members.title': '成员',
28 'space.members.sub': '查看并搜索这个社区里的成员。',
29 'space.members.recent': '最近加入的成员',
30 'space.members.shown': '已显示 {shown} 位,共 {total} 位成员',
31 'space.members.sort': '排序',
32 'space.members.sortJoined': '最近加入',
33 'space.members.sortName': '按名称',
34 'space.members.rowsPerPage': '每页行数',
35 'space.members.col.name': '名称',
36 'space.members.col.joined': '加入时间',
37 'space.members.col.method': '加入方式',
38 'space.members.col.status': '状态',
39 'space.members.owner': '社区创建者',
40 'space.members.invitedBy': '由 {name} 邀请',
41 'space.members.joinedSelf': '自行加入',
42 'space.members.noMatch': '没有匹配的成员',
43 'space.members.refresh': '刷新成员列表',
44 'space.members.open': '查看成员列表',
2845 'space.members.invite': '邀请成员',
2946 'space.members.remove': '移除成员',
3047 'space.members.removed': '成员已移除。',
@@ -32,6 +49,18 @@
3249 'space.members.role': '角色',
3350 'space.members.joinedAt': '加入时间',
3451 'space.roles.title': '角色',
52 'space.roles.groupCaption': '身份组',
53 'space.roles.back': '返回',
54 'space.roles.editorSub': '配置身份组设置和权限,保存后立即生效。',
55 'space.roles.settingsGroup': '显示',
56 'space.roles.editTitle': '编辑“{name}”',
57 'space.roles.permissionSearch': '搜索权限',
58 'space.roles.scopeCommunity': '社区范围',
59 'space.roles.clearPermissions': '清除权限',
60 'space.roles.colorHint': '直接改这个值,支持 hex / rgb / hsl 或颜色名。',
61 'space.roles.emptyHint': '还没有角色,点上面的「创建身份组」新建一个。',
62 'space.roles.cancel': '取消',
63 'space.roles.noPermissionMatch': '没有匹配的权限',
3564 'space.roles.create': '新建角色',
3665 'space.roles.name': '角色名称',
3766 'space.roles.permissions': '权限',
@@ -176,8 +205,11 @@
176205 'space.privacy.title': '隐私设置',
177206 'space.privacy.description': '个人账号的私信与提及权限,对全部社区生效。',
178207 'space.privacy.dms': '允许私信',
208 'space.privacy.dmsHint': '允许他人给你发私信',
179209 'space.privacy.mentions': '允许提及',
210 'space.privacy.mentionsHint': '允许他人在消息里提及你',
180211 'space.privacy.online': '展示在线状态',
212 'space.privacy.onlineHint': '在成员列表里展示你的在线状态',
181213 'space.privacy.saved': '隐私设置已保存。',
182214 'space.leave.title': '退出社区',
183215 'space.leave.sub': '退出后需要重新受邀才能回到 {space}。',
@@ -277,6 +309,8 @@
277309 'space.image.embedSplash': '嵌入背景图',
278310 'space.image.embedSplashHint': '外部嵌入页面使用',
279311 'space.settings.sections.mine': '我在本社区',
312 'space.settings.group.community': '社区',
313 'space.settings.group.members': '成员与权限',
280314 'space.settings.sections.general': '社区资料',
281315 'space.settings.sections.images': '图标与横幅',
282316 'space.settings.sections.join': '加入规则',
Modified web/src/shared/ui/app-shell.tsx web/src/shared/ui/app-shell.tsx +15 −13 View web/src/shared/ui/app-shell.tsx
@@ -67,19 +67,21 @@
6767 <div id="workspace-navigation" ref={navigation} className="workspace-navigation" role={navigationOpen ? 'dialog' : undefined} aria-modal={navigationOpen || undefined} aria-label={t('ui.navigation')} onClick={event => {
6868 if ((event.target as HTMLElement).closest('a[href]')) setNavigationOpen(false)
6969 }}>
70 <aside className="workspace-rail" aria-label={t('ui.spaceList')}>{rail}</aside>
71 <aside className="workspace-sidebar" aria-label={t('ui.currentSpace')}>
72 <div className="workspace-sidebar-header">
73 {sideHeader ?? <><span className="min-w-0 flex-1 truncate font-semibold">{t('ui.personalSpace')}</span><ChevronDown size={16} className="text-shell-ink-muted" /></>}
74 <button type="button" className="workspace-drawer-close workspace-icon-button" aria-label={t('ui.collapseNavigation')} onClick={() => setNavigationOpen(false)}><X size={18} /></button>
75 </div>
76 {navPinned ? (
77 <div className="workspace-nav-pinned" aria-label={t('ui.shortcuts')}>{navPinned}</div>
78 ) : null}
79 <div className="workspace-nav-caption"><span>{t('ui.workspace')}</span><button type="button" className="workspace-collapse workspace-icon-button" aria-label={t('ui.collapseSidebar')} title={t('ui.collapseSidebar')} onClick={() => setCollapsed(true)}><PanelLeftClose size={16} /></button></div>
80 <nav className="workspace-nav" aria-label={t('ui.navScope')}>{nav}</nav>
81 <div className="workspace-sidebar-footer">{sideFooter}</div>
82 </aside>
70 <div className="workspace-navigation-columns">
71 <aside className="workspace-rail" aria-label={t('ui.spaceList')}>{rail}</aside>
72 <aside className="workspace-sidebar" aria-label={t('ui.currentSpace')}>
73 <div className="workspace-sidebar-header">
74 {sideHeader ?? <><span className="min-w-0 flex-1 truncate font-semibold">{t('ui.personalSpace')}</span><ChevronDown size={16} className="text-shell-ink-muted" /></>}
75 <button type="button" className="workspace-drawer-close workspace-icon-button" aria-label={t('ui.collapseNavigation')} onClick={() => setNavigationOpen(false)}><X size={18} /></button>
76 </div>
77 {navPinned ? (
78 <div className="workspace-nav-pinned" aria-label={t('ui.shortcuts')}>{navPinned}</div>
79 ) : null}
80 <div className="workspace-nav-caption"><span>{t('ui.workspace')}</span><button type="button" className="workspace-collapse workspace-icon-button" aria-label={t('ui.collapseSidebar')} title={t('ui.collapseSidebar')} onClick={() => setCollapsed(true)}><PanelLeftClose size={16} /></button></div>
81 <nav className="workspace-nav" aria-label={t('ui.navScope')}>{nav}</nav>
82 </aside>
83 </div>
84 {sideFooter ? <div className="workspace-navigation-footer">{sideFooter}</div> : null}
8385 </div>
8486 <main id="workspace-main" tabIndex={-1} className="workspace-main" inert={navigationOpen || undefined}>
8587 {/* Always mounted: `visibility` hides it when the sidebar is open, which keeps
Modified web/src/shared/ui/settings-modal.tsx web/src/shared/ui/settings-modal.tsx +123 −20 View web/src/shared/ui/settings-modal.tsx
@@ -1,10 +1,17 @@
1import { useEffect, type ReactNode } from 'react'
2import { X } from 'lucide-react'
1import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react'
2import { Link } from 'react-router-dom'
3import { SquareArrowOutUpRight, X, type LucideIcon } from 'lucide-react'
34import { useT } from '@/shared/i18n'
45
56export interface SettingsSection {
67 id: string
78 label: string
9 /** Row glyph, mirroring the reference settings dialog. */
10 icon?: LucideIcon
11 /** Rows sharing a group sit under one small caption. */
12 group?: string
13 /** Rows that leave the dialog for a full page instead of switching panes. */
14 href?: string
815}
916
1017export interface SettingsModalShellProps {
@@ -15,6 +22,10 @@
1522 onSelect: (id: string) => void
1623 onClose: () => void
1724 children: ReactNode
25 /** Replaces the section list while a section runs its own sub-navigation. */
26 navOverride?: ReactNode
27 /** Swap key for the content pane (defaults to the active section id). */
28 contentToken?: string
1829}
1930
2031/**
@@ -31,6 +42,8 @@
3142 onSelect,
3243 onClose,
3344 children,
45 navOverride,
46 contentToken,
3447}: SettingsModalShellProps) {
3548 const { t } = useT()
3649 useEffect(() => {
@@ -41,32 +54,122 @@
4154 return () => window.removeEventListener('keydown', onKey)
4255 }, [onClose])
4356
57 const activeSection =
58 sections.find(item => item.id === active && !item.href) ?? sections.find(item => !item.href) ?? sections[0]
59
4460 return (
4561 <div className="workspace-overlay fixed inset-0 z-50 flex items-center justify-center p-6">
4662 <div role="dialog" aria-modal aria-label={title} className="settings-dialog text-shell-ink">
4763 <nav className="settings-nav" aria-label={t('ui.settingsCategories', { title })}>
48 <p>{title}</p>
49 {sections.map(item => (
64 <FadeSwap token={navOverride ? 'sub' : 'sections'} className="settings-nav-body">
65 {navOverride ?? (
66 <>
67 <p className="settings-nav-title">{title}</p>
68 {sections.map((item, index) => {
69 const Icon = item.icon
70 const opensGroup = Boolean(item.group) && item.group !== sections[index - 1]?.group
71 return (
72 <Fragment key={item.id}>
73 {opensGroup ? <p className="settings-nav-caption">{item.group}</p> : null}
74 {item.href ? (
75 <Link
76 to={item.href}
77 onClick={onClose}
78 className={`settings-nav-item ${item.id === 'danger' ? 'settings-nav-danger' : ''}`}
79 >
80 {Icon ? <Icon className="h-4 w-4 shrink-0" aria-hidden /> : null}
81 <span className="truncate">{item.label}</span>
82 <SquareArrowOutUpRight
83 className="ml-auto h-3.5 w-3.5 shrink-0 text-shell-ink-muted"
84 aria-hidden
85 />
86 </Link>
87 ) : (
88 <button
89 type="button"
90 aria-current={active === item.id ? 'page' : undefined}
91 onClick={() => onSelect(item.id)}
92 className={`settings-nav-item ${item.id === 'danger' ? 'settings-nav-danger' : ''}`}
93 >
94 {Icon ? <Icon className="h-4 w-4 shrink-0" aria-hidden /> : null}
95 <span className="truncate">{item.label}</span>
96 </button>
97 )}
98 </Fragment>
99 )
100 })}
101 </>
102 )}
103 </FadeSwap>
104 </nav>
105 <div className="settings-pane">
106 <header className="settings-pane-header">
107 <h2
108 key={activeSection?.id}
109 className={`settings-swap-in ${activeSection?.id === 'danger' ? 'text-[#f23f43]' : ''}`}
110 >
111 {activeSection?.label ?? title}
112 </h2>
50113 <button
51 key={item.id}
52114 type="button"
53 aria-current={active === item.id ? 'page' : undefined}
54 onClick={() => onSelect(item.id)}
55 className="w-full text-left"
115 aria-label={t('ui.closeSettings')}
116 onClick={onClose}
117 className="settings-close workspace-icon-button"
56118 >
57 {item.label}
119 <X className="h-5 w-5" />
58120 </button>
59 ))}
60 </nav>
61 <div className="settings-content">{children}</div>
62 <button
63 type="button"
64 aria-label={t('ui.closeSettings')}
65 onClick={onClose}
66 className="settings-close workspace-icon-button"
67 >
68 <X className="h-5 w-5" />
69 </button>
121 </header>
122 <FadeSwap token={contentToken ?? active} className="settings-content">
123 {children}
124 </FadeSwap>
125 </div>
126 </div>
127 </div>
128 )
129}
130
131/**
132 * Cross-fades a swapped subtree: the outgoing tree is parked on top while the
133 * replacement fades in, so the pane never flashes empty and never shifts.
134 */
135function FadeSwap({
136 token,
137 className,
138 children,
139}: {
140 token: string
141 className?: string
142 children: ReactNode
143}) {
144 const [current, setCurrent] = useState<{ token: string; node: ReactNode }>({ token, node: children })
145 const [outgoing, setOutgoing] = useState<{ token: string; node: ReactNode } | null>(null)
146 const frame = useRef<HTMLDivElement>(null)
147 const shown = useRef({ token, node: children })
148 const exitTimer = useRef<number | undefined>(undefined)
149
150 useEffect(() => {
151 const previous = shown.current
152 shown.current = { token, node: children }
153 setCurrent(shown.current)
154 // Same view: only refresh the children (async data, edits) — no fade.
155 if (token === previous.token) return
156 setOutgoing(previous)
157 frame.current?.scrollTo({ top: 0 })
158 window.clearTimeout(exitTimer.current)
159 exitTimer.current = window.setTimeout(() => setOutgoing(null), 220)
160 }, [token, children])
161
162 useEffect(() => () => window.clearTimeout(exitTimer.current), [])
163
164 return (
165 <div ref={frame} className={`settings-swap ${className ?? ''}`}>
166 {outgoing ? (
167 <div key={outgoing.token} className="settings-swap-out" aria-hidden>
168 {outgoing.node}
169 </div>
170 ) : null}
171 <div key={current.token} className="settings-swap-in">
172 {current.node}
70173 </div>
71174 </div>
72175 )
Modified web/src/shared/ui/settings-primitives.tsx web/src/shared/ui/settings-primitives.tsx +1 −1 View web/src/shared/ui/settings-primitives.tsx
@@ -10,7 +10,7 @@
1010
1111export function SectionTitle({ title, sub }: { title: string; sub?: string }) {
1212 return (
13 <div className="mb-6">
13 <div className="settings-section-head mb-6">
1414 <h2 className="text-[19px] font-bold">{title}</h2>
1515 {sub ? <p className="mt-1 text-[13px] text-shell-ink-muted">{sub}</p> : null}
1616 </div>