feat(web/app): add the issues domain
Port boards, issue detail, comments, labels, milestones and tracked time. Issue detail now subscribes to the issue event family filtered by issue_id and revalidates in place, so edits from another client appear without a refresh.
ZhenYi committed
Added view/app/src/domains/issues/model/api.ts view/app/src/domains/issues/model/api.ts +358 −0 View view/app/src/domains/issues/model/api.ts
@@ -0,0 +1,358 @@
1/**
2 * Issue-board transport: every call the 问题集 pages make, wrapped in the
3 * shared envelope contract so the UI only ever sees unwrapped rows or an
4 * `ApiFailure`.
5 *
6 * The generated client types paged endpoints against the generic `Page`
7 * schema (one shape for every list), so the row type is asserted here — the
8 * single place in the app allowed to do that, which keeps the casts out of the
9 * components and makes a schema drift show up as one localized error.
10 *
11 * Issues are addressable by uuid *or* board-scoped number (`?issue=42`), so
12 * every `issueId` parameter below accepts either form.
13 */
14
15import { type ApiEnvelope, unwrapApi } from '@app/lib'
16import Apis from '@app/services/api'
17import type {
18 Board,
19 CommentNode,
20 IssueAttachment,
21 IssueComment,
22 IssueDetail,
23 IssueEvent,
24 IssueLabel,
25 IssuePage,
26 IssuePatchCmd,
27 IssueQuery,
28 IssueState,
29 IssueUploadIntent,
30 Milestone,
31 NewIssueCmd,
32 SpaceMember,
33 WatchMode,
34} from './issue'
35import { SORT_PARAMS } from './meta'
36
37/**
38 * Empty strings are dropped, not sent: Alova omits `undefined` query values but
39 * forwards `''`, and the backend's `Option<Uuid>` / `Option<IssueState>`
40 * deserialization rejects an empty `milestone_id=` / `state=` with a 400.
41 */
42const nz = <T>(value: T | '' | null | undefined): T | undefined => (value ? value : undefined)
43
44/** Empty selections are dropped; several become `a,b` (see the backend parser). */
45const csv = (values: readonly string[] | undefined): string | undefined =>
46 values && values.length > 0 ? values.join(',') : undefined
47
48/** Board id path params repeated by almost every issue endpoint. */
49export interface BoardRef {
50 space: string
51 board: string
52}
53
54const pageOf = <T>(method: PromiseLike<ApiEnvelope<unknown>>): Promise<IssuePage<T>> =>
55 unwrapApi<IssuePage<T> | null>(method as PromiseLike<ApiEnvelope<IssuePage<T> | null>>).then((p) => ({
56 items: p?.items ?? [],
57 total: p?.total ?? 0,
58 limit: p?.limit ?? 0,
59 offset: p?.offset ?? 0,
60 }))
61
62const listOf = <T>(method: PromiseLike<ApiEnvelope<unknown>>): Promise<T[]> =>
63 unwrapApi<T[] | null>(method as PromiseLike<ApiEnvelope<T[] | null>>).then((rows) => rows ?? [])
64
65const oneOf = <T>(method: PromiseLike<ApiEnvelope<unknown>>): Promise<T> =>
66 unwrapApi<T>(method as PromiseLike<ApiEnvelope<T>>)
67
68/* ------------------------------------------------------------------ board */
69
70export const getBoard = ({ space, board }: BoardRef) =>
71 unwrapApi<Board>(Apis.issues.get_board({ pathParams: { space_name: space, board_id: board } }))
72
73export const updateBoard = (
74 { space, board }: BoardRef,
75 cmd: { name?: string; prefix?: string | null; description?: string | null; icon?: string | null },
76) => oneOf<Board>(Apis.issues.update_board({ pathParams: { space_name: space, board_id: board }, data: cmd }))
77
78export const deleteBoard = ({ space, board }: BoardRef) =>
79 unwrapApi<null>(Apis.issues.delete_board({ pathParams: { space_name: space, board_id: board } }))
80
81/* ------------------------------------------------------------------ issue */
82
83/** One page of issues with the board's filters applied server-side. */
84export const listIssues = ({ space, board }: BoardRef, query: IssueQuery = {}) =>
85 pageOf<import('./issue').IssueListItem>(
86 Apis.issues.list_issues({
87 pathParams: { space_name: space, board_id: board },
88 params: {
89 // `all` is a UI-only choice: the backend filters "open" or "closed", and
90 // omitting the key at all is what "no state filter" means.
91 state: query.state && query.state !== 'all' ? (query.state as IssueState) : undefined,
92 q: nz(query.q),
93 // Multi-select travels as one comma-separated value: the query string
94 // stays readable and the OpenAPI param stays a plain string.
95 priority: csv(query.priorities),
96 label_id: csv(query.labelIds),
97 milestone_id: csv(query.milestoneIds),
98 assignee_id: csv(query.assigneeIds),
99 author_id: nz(query.authorId),
100 sort: query.sort ? SORT_PARAMS[query.sort] : undefined,
101 limit: query.limit,
102 offset: query.offset ?? 0,
103 },
104 }),
105 )
106
107export const getIssue = ({ space, board }: BoardRef, issueId: string) =>
108 unwrapApi<IssueDetail>(
109 Apis.issues.get_issue({ pathParams: { space_name: space, board_id: board, issue_id: issueId } }),
110 )
111
112export const createIssue = ({ space, board }: BoardRef, cmd: NewIssueCmd) =>
113 oneOf<IssueDetail>(
114 Apis.issues.create_issue({
115 pathParams: { space_name: space, board_id: board },
116 data: {
117 title: cmd.title,
118 description: cmd.description ?? null,
119 content: cmd.content ?? '',
120 priority: cmd.priority ?? 'none',
121 due_at: cmd.due_at ?? null,
122 milestone_id: cmd.milestone_id ?? null,
123 assignee_ids: cmd.assignee_ids ?? [],
124 label_ids: cmd.label_ids ?? [],
125 attachment_ids: cmd.attachment_ids ?? [],
126 },
127 }),
128 )
129
130export const updateIssue = ({ space, board }: BoardRef, issueId: string, cmd: IssuePatchCmd) =>
131 oneOf<IssueDetail>(
132 Apis.issues.update_issue({
133 pathParams: { space_name: space, board_id: board, issue_id: issueId },
134 data: cmd,
135 }),
136 )
137
138export const closeIssue = ({ space, board }: BoardRef, issueId: string) =>
139 oneOf<IssueDetail>(Apis.issues.close_issue({ pathParams: { space_name: space, board_id: board, issue_id: issueId } }))
140
141export const reopenIssue = ({ space, board }: BoardRef, issueId: string) =>
142 oneOf<IssueDetail>(
143 Apis.issues.reopen_issue({ pathParams: { space_name: space, board_id: board, issue_id: issueId } }),
144 )
145
146export const deleteIssue = ({ space, board }: BoardRef, issueId: string) =>
147 unwrapApi<null>(Apis.issues.delete_issue({ pathParams: { space_name: space, board_id: board, issue_id: issueId } }))
148
149export const setIssueWatch = ({ space, board }: BoardRef, issueId: string, mode: WatchMode) =>
150 unwrapApi<unknown>(
151 Apis.issues.upsert_issue_watch({
152 pathParams: { space_name: space, board_id: board, issue_id: issueId },
153 data: { mode },
154 }),
155 ).catch(() => null)
156
157/* ---------------------------------------------------------------- comment */
158
159export const listCommentTree = ({ space, board }: BoardRef, issueId: string) =>
160 listOf<CommentNode>(
161 Apis.issues.list_comments_tree({
162 pathParams: { space_name: space, board_id: board, issue_id: issueId },
163 }),
164 )
165
166export const createComment = ({ space, board }: BoardRef, issueId: string, content: string, parentId?: string) =>
167 oneOf<IssueComment>(
168 Apis.issues.create_comment({
169 pathParams: { space_name: space, board_id: board, issue_id: issueId },
170 data: { content, parent_id: parentId ?? null },
171 }),
172 )
173
174export const updateComment = (ref: BoardRef, issueId: string, commentId: string, content: string) =>
175 oneOf<IssueComment>(
176 Apis.issues.update_comment({
177 pathParams: {
178 space_name: ref.space,
179 board_id: ref.board,
180 issue_id: issueId,
181 comment_id: commentId,
182 },
183 data: { content },
184 }),
185 )
186
187export const deleteComment = (ref: BoardRef, issueId: string, commentId: string) =>
188 unwrapApi<null>(
189 Apis.issues.delete_comment({
190 pathParams: {
191 space_name: ref.space,
192 board_id: ref.board,
193 issue_id: issueId,
194 comment_id: commentId,
195 },
196 }),
197 )
198
199/* ------------------------------------------------------------------ meta */
200
201export const listEvents = ({ space, board }: BoardRef, issueId: string, limit = 50) =>
202 pageOf<IssueEvent>(
203 Apis.issues.list_events({
204 pathParams: { space_name: space, board_id: board, issue_id: issueId },
205 params: { limit },
206 }),
207 )
208
209export const listLabels = ({ space, board }: BoardRef) =>
210 pageOf<IssueLabel>(
211 Apis.issues.list_labels({
212 pathParams: { space_name: space, board_id: board },
213 params: { limit: 100, is_archived: false },
214 }),
215 )
216
217export const createLabel = (
218 { space, board }: BoardRef,
219 cmd: { name: string; color: string; description?: string | null },
220) =>
221 oneOf<IssueLabel>(
222 Apis.issues.create_label({
223 pathParams: { space_name: space, board_id: board },
224 data: { name: cmd.name, color: cmd.color, description: cmd.description ?? null },
225 }),
226 )
227
228export const updateLabel = (
229 { space, board }: BoardRef,
230 labelId: string,
231 cmd: { name?: string; color?: string; description?: string | null; is_archived?: boolean },
232) =>
233 oneOf<IssueLabel>(
234 Apis.issues.update_label({
235 pathParams: { space_name: space, board_id: board, label_id: labelId },
236 data: cmd,
237 }),
238 )
239
240export const deleteLabel = ({ space, board }: BoardRef, labelId: string) =>
241 unwrapApi<null>(Apis.issues.delete_label({ pathParams: { space_name: space, board_id: board, label_id: labelId } }))
242
243/** Labels currently bound to one issue (`GET …/issues/{id}/labels`). */
244export const listIssueLabels = ({ space, board }: BoardRef, issueId: string) =>
245 listOf<IssueLabel>(
246 Apis.issues.list_labels_for_issue({
247 pathParams: { space_name: space, board_id: board, issue_id: issueId },
248 }),
249 )
250
251export const bindLabel = ({ space, board }: BoardRef, issueId: string, labelId: string) =>
252 unwrapApi<null>(
253 Apis.issues.bind_label({
254 pathParams: { space_name: space, board_id: board, issue_id: issueId, label_id: labelId },
255 }),
256 )
257
258export const unbindLabel = ({ space, board }: BoardRef, issueId: string, labelId: string) =>
259 unwrapApi<null>(
260 Apis.issues.unbind_label({
261 pathParams: { space_name: space, board_id: board, issue_id: issueId, label_id: labelId },
262 }),
263 )
264
265export const listMilestones = ({ space, board }: BoardRef, includeClosed = true) =>
266 pageOf<Milestone>(
267 Apis.issues.list_milestones({
268 pathParams: { space_name: space, board_id: board },
269 params: { limit: 100, ...(includeClosed ? {} : { is_closed: false }) },
270 }),
271 )
272
273export const createMilestone = (
274 { space, board }: BoardRef,
275 cmd: { name: string; description?: string | null; due_at?: string | null },
276) =>
277 oneOf<Milestone>(
278 Apis.issues.create_milestone({
279 pathParams: { space_name: space, board_id: board },
280 data: { name: cmd.name, description: cmd.description ?? null, due_at: cmd.due_at ?? null },
281 }),
282 )
283
284export const updateMilestone = (
285 { space, board }: BoardRef,
286 milestoneId: string,
287 cmd: { name?: string; description?: string | null; due_at?: string | null; is_closed?: boolean },
288) =>
289 oneOf<Milestone>(
290 Apis.issues.update_milestone({
291 pathParams: { space_name: space, board_id: board, milestone_id: milestoneId },
292 data: cmd,
293 }),
294 )
295
296export const deleteMilestone = ({ space, board }: BoardRef, milestoneId: string) =>
297 unwrapApi<null>(
298 Apis.issues.delete_milestone({
299 pathParams: { space_name: space, board_id: board, milestone_id: milestoneId },
300 }),
301 )
302
303/* ---------------------------------------------------------------- members */
304
305/** Assignable people: the space's members (issue assignees must be members). */
306export const listSpaceMembers = (space: string) =>
307 pageOf<SpaceMember>(Apis.space.list_space_members({ pathParams: { space_name: space }, params: { limit: 100 } }))
308
309/* ------------------------------------------------------------ attachments */
310
311/**
312 * Attachments follow the chat upload shape: reserve a row + presigned PUT,
313 * upload the bytes straight to the object store, then finalize (which verifies
314 * the real size/type). A draft is linked to the issue by `createIssue`'s
315 * `attachment_ids`; only finalized drafts can be linked.
316 */
317export const createIssueAttachment = (cmd: {
318 space_name: string
319 filename: string
320 content_type: string
321 size: number
322}) => oneOf<IssueUploadIntent>(Apis.issues.create_issue_attachment({ data: cmd }))
323
324export const finalizeIssueAttachment = (attachmentId: string) =>
325 oneOf<IssueAttachment>(
326 Apis.issues.finalize_issue_attachment({
327 pathParams: { attachment_id: attachmentId },
328 }),
329 )
330
331export const deleteIssueAttachment = (attachmentId: string) =>
332 unwrapApi<null>(Apis.issues.delete_issue_attachment({ pathParams: { attachment_id: attachmentId } }))
333
334export const listIssueAttachments = ({ space, board }: BoardRef, issueId: string) =>
335 listOf<IssueAttachment>(
336 Apis.issues.list_issue_attachments({
337 pathParams: { space_name: space, board_id: board, issue_id: issueId },
338 }),
339 )
340
341/** Same-origin download/file URL for one attachment (works for `<img>` too). */
342export function issueAttachmentUrl(attachmentId: string): string {
343 return `/api/v1/issues/attachments/${encodeURIComponent(attachmentId)}/file`
344}
345
346/**
347 * PUT the raw bytes to the presigned URL. The object store allows `*` origins,
348 * so the browser talks to it directly and a dead bucket never looks like a
349 * dead API to the outage gate.
350 */
351export async function putIssueUpload(uploadUrl: string, file: File): Promise<void> {
352 const response = await fetch(uploadUrl, {
353 method: 'PUT',
354 headers: { 'content-type': file.type || 'application/octet-stream' },
355 body: file,
356 })
357 if (!response.ok) throw new Error(`upload failed: ${response.status}`)
358}
Added view/app/src/domains/issues/model/index.ts view/app/src/domains/issues/model/index.ts +6 −0 View view/app/src/domains/issues/model/index.ts
@@ -0,0 +1,6 @@
1// Public API of the entities/issue slice — issue-board domain data.
2// Consumers import from '@app/domains/issues/model', never from deeper files.
3
4export * from './api'
5export * from './issue'
6export * from './meta'
Added view/app/src/domains/issues/model/issue.ts view/app/src/domains/issues/model/issue.ts +168 −0 View view/app/src/domains/issues/model/issue.ts
@@ -0,0 +1,168 @@
1/**
2 * Issue-board (问题集) row shapes.
3 *
4 * Types are derived from the generated response envelopes wherever the backend
5 * exposes one, so a schema change in `bun run gen:api` shows up here as a type
6 * error instead of a runtime surprise. Paged payloads are typed as the generic
7 * `Page` schema by the generator, so their item rows are declared structurally
8 * (they mirror `IssueListItemDto` / `EventDto` in `lib/issues`).
9 */
10import type {
11 EnvelopeBoardDto,
12 EnvelopeCommentDto,
13 EnvelopeIssueAttachmentDto,
14 EnvelopeIssueDto,
15 EnvelopeIssueUploadIntentDto,
16 EnvelopeLabelDto,
17 EnvelopeMilestoneDto,
18 EnvelopeVecCommentTreeNode,
19 IssuePriority,
20 IssueState,
21 UserDto,
22 WatchMode,
23} from '@app/services/api'
24
25export type { IssuePriority, IssueState, UserDto, WatchMode }
26
27/** One issue board (`GET /issues/{space}/boards`). */
28export type Board = NonNullable<EnvelopeBoardDto['data']>
29
30/** Full issue payload (`GET …/issues/{issue_id}`). */
31export type IssueDetail = NonNullable<EnvelopeIssueDto['data']>
32
33/** Board label (`GET …/labels`). */
34export type IssueLabel = NonNullable<EnvelopeLabelDto['data']>
35
36/** Milestone (`GET …/milestones`). */
37export type Milestone = NonNullable<EnvelopeMilestoneDto['data']>
38
39/** One issue attachment (`GET …/issues/{id}/attachments`). */
40export type IssueAttachment = NonNullable<EnvelopeIssueAttachmentDto['data']>
41
42/** Presigned upload intent (`POST /issues/attachments`). */
43export type IssueUploadIntent = NonNullable<EnvelopeIssueUploadIntentDto['data']>
44
45/** Issue comment, flat or inside a thread tree. */
46export type IssueComment = NonNullable<EnvelopeCommentDto['data']>
47export type CommentNode = NonNullable<NonNullable<EnvelopeVecCommentTreeNode['data']>[number]>
48
49/** Row of `GET …/issues` — the list endpoint omits body/description. */
50export interface IssueListItem {
51 id: string
52 board_id: string
53 number: number
54 author?: UserDto | null
55 title: string
56 labels: IssueLabel[]
57 milestone_id?: string | null
58 priority: IssuePriority
59 due_at?: string | null
60 assignees: UserDto[]
61 state: IssueState
62 created_at: string
63 updated_at?: string | null
64 closed_at?: string | null
65}
66
67/** Activity line of `GET …/issues/{id}/events`. */
68export interface IssueEvent {
69 id: string
70 issue_id?: string | null
71 user?: UserDto | null
72 event_type: string
73 comment_id?: string | null
74 commit_sha?: string | null
75 old_title?: string | null
76 new_title?: string | null
77 old_ref?: string | null
78 new_ref?: string | null
79 content?: string | null
80 created_at: string
81}
82
83/** Space member row used by the assignee picker. */
84export interface SpaceMember {
85 user?: UserDto | null
86 alias_name?: string | null
87 status?: string | null
88}
89
90/** Offset page as emitted by the backend (`{items,total,limit,offset}`). */
91export interface IssuePage<T> {
92 items: T[]
93 total: number
94 limit: number
95 offset: number
96}
97
98/** Filters accepted by `GET …/issues` (plus paging). */
99export interface IssueQuery {
100 state?: IssueState | 'all'
101 q?: string
102 /** Multi-select: any of these priorities matches. */
103 priorities?: IssuePriority[]
104 labelIds?: string[]
105 /** `none` is a UI-only token meaning "no milestone" (filtered client-side). */
106 milestoneIds?: string[]
107 assigneeIds?: string[]
108 authorId?: number
109 sort?: IssueSort
110 limit?: number
111 offset?: number
112}
113
114/** Sort keys the backend understands; `created` is its default (newest first). */
115export type IssueSort = 'created' | 'updated' | 'due' | 'priority' | 'number'
116
117/**
118 * The board page's view state. The route persists it (see
119 * `pages/workspace/model/board-location`); the feature components only ever
120 * receive it as a plain object, so no UI slice depends on the page layer.
121 */
122export interface IssueBoardView {
123 /** `board` = kanban grouped by priority, `list` = table. */
124 view: 'board' | 'list'
125 state: IssueState | 'all'
126 /** Title keyword (backend `q`). */
127 q: string
128 /** Filter panel dimensions; empty arrays mean "no constraint". */
129 priorities: IssuePriority[]
130 labelIds: string[]
131 milestoneIds: string[]
132 assigneeIds: string[]
133 sort: IssueSort
134 /** 1-based page within the filtered result set. */
135 page: number
136 /** Open issue: board-scoped number (`42`) or uuid. Empty = board/list. */
137 issue: string
138}
139
140/** Body of `POST …/issues`. */
141export interface NewIssueCmd {
142 title: string
143 description?: string | null
144 content?: string
145 priority?: IssuePriority
146 due_at?: string | null
147 milestone_id?: string | null
148 assignee_ids?: number[]
149 label_ids?: string[]
150 /** Draft upload ids from `POST /issues/attachments` to link to the new issue. */
151 attachment_ids?: string[]
152}
153
154/**
155 * Body of `PATCH …/issues/{id}`. Three-state fields: absent = unchanged,
156 * `null` = cleared, value = set — so the caller must not send `undefined`
157 * for a field it wants cleared.
158 */
159export interface IssuePatchCmd {
160 title?: string
161 description?: string | null
162 content?: string
163 priority?: IssuePriority
164 due_at?: string | null
165 milestone_id?: string | null
166 assignee_ids?: number[]
167 label_ids?: string[]
168}
Added view/app/src/domains/issues/model/meta.ts view/app/src/domains/issues/model/meta.ts +323 −0 View view/app/src/domains/issues/model/meta.ts
@@ -0,0 +1,323 @@
1/**
2 * Display metadata for issue rows: the labels, colours and ordering the board
3 * UI shows for values the API only names. Kept next to the domain (not in a
4 * component) because the kanban columns, the filter chips, the list table and
5 * the detail sidebar all have to agree on them.
6 */
7import { getLocale, isMessageKey, t } from '@app/i18n'
8import { fmtRelative } from '@app/lib'
9import type { IssueBoardView, IssuePriority, IssueSort, IssueState, WatchMode } from './issue'
10
11/** `issues.attachment.max_size` — the backend rejects anything larger. */
12export const MAX_ATTACHMENT_BYTES = 26_214_400
13
14/** `issues.max_board_prefix_len` — mirrored so the dialog validates before sending. */
15export const MAX_BOARD_PREFIX_LEN = 10
16
17/**
18 * Board-scoped issue key as the backend resolves it (`CAD-12`), falling back to
19 * the bare number so boards without a prefix keep today's links.
20 */
21export function issueRef(board: { prefix?: string | null } | null | undefined, number: number): string {
22 const prefix = board?.prefix?.trim()
23 return prefix ? `${prefix}-${number}` : String(number)
24}
25
26/** Display form of {@link issueRef}: `CAD-12`, or `#12` without a prefix. */
27export function issueLabel(board: { prefix?: string | null } | null | undefined, number: number): string {
28 const prefix = board?.prefix?.trim()
29 return prefix ? `${prefix}-${number}` : `#${number}`
30}
31
32/**
33 * Row stamp, Linear-style: relative while the change is fresh, then the month
34 * (`2026年10月` / `Oct 2026`) once "3天前" stops being useful.
35 */
36export function issueStamp(value: string | null | undefined): string {
37 const date = parseIssueDate(value)
38 if (!date) return ''
39 if (Date.now() - date.getTime() < 7 * 86_400_000) return fmtRelative(value as string)
40 return new Intl.DateTimeFormat(getLocale(), { month: 'short', year: 'numeric' }).format(date)
41}
42
43/** `^[A-Z][A-Z0-9]{0,9}$` — the shape `PATCH …/boards` accepts. */
44export function isValidBoardPrefix(value: string): boolean {
45 return /^[A-Z][A-Z0-9]{0,9}$/.test(value)
46}
47
48export interface PriorityMeta {
49 id: IssuePriority
50 label: string
51 /** Column dot / badge background. */
52 color: string
53 /** Badge text colour that stays readable on `color`. */
54 text: string
55 /** High → low, used for client-side ordering and column order. */
56 rank: number
57}
58
59/** Highest priority first — matches `?sort=priority` (backend sorts desc). */
60export const PRIORITIES: readonly PriorityMeta[] = [
61 {
62 id: 'urgent',
63 get label() {
64 return t('label.priority.urgent')
65 },
66 color: '#e5484d',
67 text: '#ffffff',
68 rank: 4,
69 },
70 {
71 id: 'high',
72 get label() {
73 return t('label.priority.high')
74 },
75 color: '#f76808',
76 text: '#ffffff',
77 rank: 3,
78 },
79 {
80 id: 'medium',
81 get label() {
82 return t('label.priority.medium')
83 },
84 color: '#ffc53d',
85 text: '#3d2a00',
86 rank: 2,
87 },
88 {
89 id: 'low',
90 get label() {
91 return t('label.priority.low')
92 },
93 color: '#8fb8ff',
94 text: '#12315e',
95 rank: 1,
96 },
97 {
98 id: 'none',
99 get label() {
100 return t('label.priority.none')
101 },
102 color: '#d6d9e0',
103 text: '#3f434b',
104 rank: 0,
105 },
106] as const
107
108export const PRIORITY_META: Record<IssuePriority, PriorityMeta> = PRIORITIES.reduce(
109 (acc, meta) => ({ ...acc, [meta.id]: meta }),
110 {} as Record<IssuePriority, PriorityMeta>,
111)
112
113export function priorityOf(value: string | null | undefined): PriorityMeta {
114 return PRIORITY_META[value as IssuePriority] ?? PRIORITY_META.none
115}
116
117export const STATES: Readonly<Record<IssueState, { label: string; color: string }>> = {
118 open: {
119 get label() {
120 return t('label.issueState.open')
121 },
122 color: '#3ba55d',
123 },
124 closed: {
125 get label() {
126 return t('label.issueState.closed')
127 },
128 color: '#66717d',
129 },
130}
131
132export const STATE_FILTERS: ReadonlyArray<{ id: IssueState | 'all'; label: string }> = [
133 {
134 id: 'open',
135 get label() {
136 return t('label.issueState.open')
137 },
138 },
139 {
140 id: 'closed',
141 get label() {
142 return t('label.issueState.closed')
143 },
144 },
145 {
146 id: 'all',
147 get label() {
148 return t('label.issueState.all')
149 },
150 },
151]
152
153export const SORTS: ReadonlyArray<{ id: IssueSort; label: string }> = [
154 {
155 id: 'created',
156 get label() {
157 return t('label.issueSort.created')
158 },
159 },
160 {
161 id: 'updated',
162 get label() {
163 return t('label.issueSort.updated')
164 },
165 },
166 {
167 id: 'priority',
168 get label() {
169 return t('label.issueSort.priority')
170 },
171 },
172 {
173 id: 'due',
174 get label() {
175 return t('label.issueSort.due')
176 },
177 },
178 {
179 id: 'number',
180 get label() {
181 return t('label.issueSort.number')
182 },
183 },
184]
185
186/**
187 * The two layouts a 问题集 can open in. Stored per user (`board_view_mode`);
188 * an unknown or absent value falls back to the kanban.
189 */
190export function normalizeBoardView(value: string | null | undefined): IssueBoardView['view'] {
191 return value === 'list' ? 'list' : 'board'
192}
193
194export const WATCH_LABELS: Readonly<Record<WatchMode, string>> = {
195 get watching() {
196 return t('issue.watch.watching')
197 },
198 get unwatched() {
199 return t('issue.watch.unwatched')
200 },
201 get ignore() {
202 return t('issue.watch.ignore')
203 },
204}
205/** Sort values the backend rejects are dropped so the query stays valid. */
206export const SORT_PARAMS: Record<IssueSort, string> = {
207 created: 'created_at',
208 updated: 'updated_at',
209 priority: 'priority',
210 due: 'due_at',
211 number: 'number',
212}
213
214/**
215 * Rust's `time` crate renders offsets as `+00:00:00` (seconds in the offset),
216 * which `Date` cannot parse. Same fold as `shared/lib/format`.
217 */
218export function parseIssueDate(value: string | null | undefined): Date | null {
219 if (!value) return null
220 const date = new Date(value.replace(/([+-]\d{2}):(\d{2}):(\d{2})$/, '$1:$2'))
221 return Number.isNaN(date.getTime()) ? null : date
222}
223
224export interface DueMeta {
225 text: string
226 /** Overdue (or due today) on a still-open issue reads as a warning. */
227 tone: 'muted' | 'due' | 'over' | 'done'
228}
229
230/** Due date as `MM-DD`, qualified for open issues only. */
231export function describeDue(value: string | null | undefined, state: IssueState): DueMeta | null {
232 const date = parseIssueDate(value)
233 if (!date) return null
234 const day = new Intl.DateTimeFormat(getLocale(), { month: 'numeric', day: 'numeric' }).format(date)
235 if (state === 'closed') return { text: day, tone: 'done' }
236 const today = new Date()
237 today.setHours(0, 0, 0, 0)
238 const start = new Date(date)
239 start.setHours(0, 0, 0, 0)
240 const days = Math.round((start.getTime() - today.getTime()) / 86_400_000)
241 // Absolute date plus a relative phrase; Intl supplies the wording so the
242 // table stays out of the translation catalogue.
243 const relative = new Intl.RelativeTimeFormat(getLocale(), { numeric: 'auto' })
244 if (days < 0) return { text: `${day} · ${relative.format(days, 'day')}`, tone: 'over' }
245 if (days === 0) return { text: `${day} · ${relative.format(0, 'day')}`, tone: 'due' }
246 if (days <= 7) return { text: `${day} · ${relative.format(days, 'day')}`, tone: 'due' }
247 return { text: day, tone: 'muted' }
248}
249
250/** `#rgb` / `rgb` / bare hex → `#rrggbb`, so a label colour never breaks CSS. */
251export function normalizeColor(value: string | null | undefined, fallback = '#8b8f99'): string {
252 const raw = (value ?? '').trim().replace(/^#/, '')
253 if (raw.length === 3)
254 return `#${raw
255 .split('')
256 .map((c) => c + c)
257 .join('')}`
258 if (raw.length === 6 && /^[0-9a-f]{6}$/i.test(raw)) return `#${raw.toLowerCase()}`
259 return fallback
260}
261
262/** Pick black or white text for a background so custom label colours stay legible. */
263export function readableText(background: string): string {
264 const hex = normalizeColor(background).slice(1)
265 const [r, g, b] = [0, 2, 4].map((i) => Number.parseInt(hex.slice(i, i + 2), 16) / 255)
266 const channel = (v: number) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)
267 const luminance = 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)
268 return luminance > 0.55 ? '#1c1e22' : '#ffffff'
269}
270
271/** Suggestions offered by the label colour picker (GitHub-ish palette). */
272export const LABEL_COLORS = [
273 '#e5484d',
274 '#f76808',
275 '#ffc53d',
276 '#46a758',
277 '#00a2c7',
278 '#66717d',
279 '#8e4ec6',
280 '#eb459e',
281 '#8b8f99',
282] as const
283
284/** `YYYY-MM-DD` for `<input type="date">`, from the backend's own timestamp. */
285export function dateInputValue(value: string | null | undefined): string {
286 const date = parseIssueDate(value)
287 if (!date) return ''
288 const pad = (n: number) => String(n).padStart(2, '0')
289 return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
290}
291
292/** Send a date-only pick as UTC noon so it never shifts a day across timezones. */
293export function dateApiValue(input: string): string | null {
294 if (!input) return null
295 const [year, month, day] = input.split('-').map(Number)
296 if (!year || !month || !day) return null
297 return new Date(Date.UTC(year, month - 1, day, 12, 0, 0)).toISOString()
298}
299
300/**
301 * Activity line labels, keyed by the backend's `IssueEventType` snake_case
302 * values. Unknown types fall back to the raw value, so a new server-side event
303 * still renders (unreadable rather than missing).
304 */
305/**
306 * Activity line labels, keyed by the backend's `IssueEventType` snake_case
307 * values. Resolved lazily so the table follows the active locale; unknown types
308 * fall back to the raw value, so a new server-side event still renders.
309 */
310export const EVENT_LABELS: Readonly<Record<string, string>> = new Proxy(
311 {},
312 {
313 get: (_target, key: string) => {
314 const entry = `issue.event.${key}`
315 return isMessageKey(entry) ? t(entry) : undefined
316 },
317 },
318)
319
320export function eventLabel(type: string | null | undefined): string {
321 if (!type) return t('issue.event.unknown')
322 return EVENT_LABELS[type] ?? t('issue.event.fallback', { type })
323}
Added view/app/src/domains/issues/model/prefetch.ts view/app/src/domains/issues/model/prefetch.ts +39 −0 View view/app/src/domains/issues/model/prefetch.ts
@@ -0,0 +1,39 @@
1import {
2 getBoard,
3 type IssueQuery,
4 listIssues,
5 listLabels,
6 listMilestones,
7 listSpaceMembers,
8} from '@app/domains/issues/model'
9import { boardKey, prefetch, spaceMembersPageKey } from '@app/lib'
10import { ISSUE_PAGE_SIZE } from './use-board-data'
11
12/**
13 * The query a board mounts with (board-location defaults: state `open`, sort
14 * `created`, page 1). Field order matters — `useBoardData` derives its cache
15 * key from `JSON.stringify(query)`, so this literal must produce the exact
16 * same string as the board view's.
17 */
18const DEFAULT_QUERY: IssueQuery = {
19 state: 'open',
20 q: undefined,
21 priorities: undefined,
22 labelIds: undefined,
23 milestoneIds: undefined,
24 assigneeIds: undefined,
25 sort: 'created',
26 limit: ISSUE_PAGE_SIZE,
27 offset: 0,
28}
29
30/** Hover prefetch for the board column: everything `useBoardData` loads. */
31export function prefetchBoardView(space: string, board: string): void {
32 if (!space || !board) return
33 const ref = { space, board }
34 void prefetch(boardKey(space, board, 'board'), () => getBoard(ref))
35 void prefetch(boardKey(space, board, 'labels'), () => listLabels(ref))
36 void prefetch(boardKey(space, board, 'milestones'), () => listMilestones(ref))
37 void prefetch(boardKey(space, board, `issues:${JSON.stringify(DEFAULT_QUERY)}`), () => listIssues(ref, DEFAULT_QUERY))
38 void prefetch(spaceMembersPageKey(space), () => listSpaceMembers(space))
39}
Added view/app/src/domains/issues/model/use-board-data.ts view/app/src/domains/issues/model/use-board-data.ts +59 −0 View view/app/src/domains/issues/model/use-board-data.ts
@@ -0,0 +1,59 @@
1/**
2 * Data loading for one issue board. Four independent requests so a single
3 * failing endpoint cannot sink the page: the issue page drives the content,
4 * while labels / milestones / members only feed filters and pickers and fall
5 * back to "加载失败" inside their own menus.
6 */
7
8import {
9 type BoardRef,
10 getBoard,
11 type IssueQuery,
12 listIssues,
13 listLabels,
14 listMilestones,
15 listSpaceMembers,
16} from '@app/domains/issues/model'
17import { boardKey, spaceMembersPageKey, useAsync } from '@app/lib'
18import { useRef } from 'react'
19
20/** Page size for the issue list; the board's cap is 100 per request. */
21export const ISSUE_PAGE_SIZE = 50
22
23export function useBoardData(ref: BoardRef, query: IssueQuery, reloadKey: number) {
24 const queryKey = JSON.stringify(query)
25 // eslint-disable-next-line react-hooks/exhaustive-deps
26 const issues = useAsync(() => listIssues(ref, query), [ref.space, ref.board, queryKey, reloadKey], {
27 cacheKey: boardKey(ref.space, ref.board, `issues:${queryKey}`),
28 })
29 /**
30 * The cache key carries the filters, so every filter change is a *cold* key
31 * and `useAsync` would drop the rows it was showing — one tick of the filter
32 * panel painted a skeleton. Keep this board's previous page on screen while
33 * the next one loads; a board switch still starts from a clean slate.
34 */
35 const lastPage = useRef<{ board: string; page: NonNullable<typeof issues.data> } | null>(null)
36 if (issues.data) lastPage.current = { board: ref.board, page: issues.data }
37 const keepLast = !issues.data && !issues.error && lastPage.current?.board === ref.board
38 const issuesState = keepLast ? { ...issues, data: lastPage.current!.page, refreshing: true } : issues
39 // eslint-disable-next-line react-hooks/exhaustive-deps
40 const board = useAsync(() => getBoard(ref), [ref.space, ref.board, reloadKey], {
41 cacheKey: boardKey(ref.space, ref.board, 'board'),
42 })
43 // eslint-disable-next-line react-hooks/exhaustive-deps
44 const labels = useAsync(() => listLabels(ref), [ref.space, ref.board, reloadKey], {
45 cacheKey: boardKey(ref.space, ref.board, 'labels'),
46 })
47 // eslint-disable-next-line react-hooks/exhaustive-deps
48 const milestones = useAsync(() => listMilestones(ref), [ref.space, ref.board, reloadKey], {
49 cacheKey: boardKey(ref.space, ref.board, 'milestones'),
50 })
51 // eslint-disable-next-line react-hooks/exhaustive-deps
52 const members = useAsync(() => listSpaceMembers(ref.space), [ref.space, reloadKey], {
53 cacheKey: spaceMembersPageKey(ref.space),
54 })
55
56 return { board, issues: issuesState, labels, milestones, members }
57}
58
59export type BoardData = ReturnType<typeof useBoardData>
Added view/app/src/domains/issues/ui/board-filters.tsx view/app/src/domains/issues/ui/board-filters.tsx +544 −0 View view/app/src/domains/issues/ui/board-filters.tsx
@@ -0,0 +1,544 @@
1/**
2 * Board chrome, in the two layers the page actually has:
3 *
4 * header — 看板/列表 tabs beside the title, primary “新建工单” on the right
5 * toolbar — 搜索 · 状态 · 排序 · 筛选 (n) · ⋯
6 * chips — “共 N 个结果” + one removable chip per applied value
7 *
8 * The 筛选 panel owns the four low-frequency dimensions (优先级 / 标签 / 里程碑 /
9 * 负责人) as multi-selects. Every toggle applies immediately — the popover stays
10 * open while the result count behind it moves, which is the feedback an “应用”
11 * button would only defer.
12 */
13
14import {
15 type IssueBoardView,
16 type IssueLabel,
17 type IssuePriority,
18 type IssueSort,
19 type IssueState,
20 type Milestone,
21 normalizeColor,
22 PRIORITIES,
23 priorityOf,
24 SORTS,
25 STATE_FILTERS,
26 type UserDto,
27} from '@app/domains/issues/model'
28import { useT } from '@app/i18n'
29import {
30 Check,
31 ChevronDown,
32 LayoutGrid,
33 List,
34 MoreHorizontal,
35 Plus,
36 Search,
37 Settings2,
38 SlidersHorizontal,
39 X,
40} from 'lucide-react'
41import { useEffect, useRef, useState } from 'react'
42import { FilterMenu } from './parts'
43
44/** Add/remove one value from a multi-select. */
45function toggleValue(values: readonly string[], value: string): string[] {
46 return values.includes(value) ? values.filter((item) => item !== value) : [...values, value]
47}
48
49/* ------------------------------------------------------------ view tabs --- */
50
51/**
52 * 看板 / 列表 is a page-level choice, not a filter: it sits with the title in
53 * the header, so the toolbar below only ever talks about narrowing a list.
54 */
55export function BoardViewTabs({
56 value,
57 onChange,
58}: {
59 value: IssueBoardView['view']
60 onChange: (view: IssueBoardView['view']) => void
61}) {
62 const { t } = useT()
63 return (
64 <div className="board-view-tabs" role="group" aria-label={t('issue.filter.viewGroup')}>
65 {(
66 [
67 ['board', t('issue.board.viewBoard'), LayoutGrid],
68 ['list', t('issue.board.viewList'), List],
69 ] as const
70 ).map(([id, label, Icon]) => (
71 <button
72 key={id}
73 type="button"
74 aria-pressed={value === id}
75 title={label}
76 onClick={() => onChange(id)}
77 className="board-view-tab"
78 >
79 <Icon className="h-3.5 w-3.5" aria-hidden />
80 {label}
81 </button>
82 ))}
83 </div>
84 )
85}
86
87/* --------------------------------------------------------------- toolbar --- */
88
89export interface BoardFiltersProps {
90 view: IssueBoardView
91 /** Rows matching the filters — shown once anything narrows the list. */
92 total: number
93 filtered: boolean
94 labels: IssueLabel[]
95 labelsFailed: boolean
96 milestones: Milestone[]
97 members: UserDto[]
98 onFilter: (updates: Partial<IssueBoardView>) => void
99 onClear: () => void
100 onCreate: () => void
101 onManage: () => void
102}
103
104export function BoardFilters({
105 view,
106 total,
107 filtered,
108 labels,
109 labelsFailed,
110 milestones,
111 members,
112 onFilter,
113 onClear,
114 onCreate,
115 onManage,
116}: BoardFiltersProps) {
117 const { t } = useT()
118 // The input keeps its own text while the debounce is pending, and re-syncs
119 // whenever the URL wins (back button, cleared chip, another tab's change).
120 const [text, setText] = useState(view.q)
121 const [panelOpen, setPanelOpen] = useState(false)
122 const [menuOpen, setMenuOpen] = useState(false)
123 const search = useRef<HTMLInputElement | null>(null)
124 const bar = useRef<HTMLDivElement | null>(null)
125
126 useEffect(() => setText(view.q), [view.q])
127 useEffect(() => {
128 if (text === view.q) return
129 const timer = setTimeout(() => onFilter({ q: text }), 300)
130 return () => clearTimeout(timer)
131 // eslint-disable-next-line react-hooks/exhaustive-deps
132 }, [text])
133
134 // Outside click closes whatever the toolbar has open.
135 useEffect(() => {
136 if (!panelOpen && !menuOpen) return
137 const onDown = (event: MouseEvent) => {
138 if (bar.current?.contains(event.target as Node)) return
139 setPanelOpen(false)
140 setMenuOpen(false)
141 }
142 window.addEventListener('mousedown', onDown)
143 return () => window.removeEventListener('mousedown', onDown)
144 }, [panelOpen, menuOpen])
145
146 /**
147 * Page shortcuts. `/` runs in the capture phase and stops propagation so the
148 * global command palette (which also claims `/`) does not swallow it; `c`
149 * creates an issue; `Esc` closes this toolbar first, then clears filters.
150 * Typing fields, open menus and open dialogs keep their own keys.
151 */
152 useEffect(() => {
153 const onKey = (event: KeyboardEvent) => {
154 if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return
155 const target = event.target as HTMLElement | null
156 const typing = Boolean(target?.closest('input, textarea, select, [contenteditable="true"]'))
157 const blocked = typing || Boolean(document.querySelector('[role="dialog"]'))
158 if (event.key === '/' && !blocked) {
159 event.preventDefault()
160 event.stopPropagation()
161 search.current?.focus()
162 return
163 }
164 if (blocked) return
165 if (event.key === 'c' || event.key === 'C') {
166 event.preventDefault()
167 onCreate()
168 return
169 }
170 if (event.key === 'Escape') {
171 // An open sort menu owns Esc until it closes.
172 if (document.querySelector('[role="listbox"]')) return
173 if (panelOpen || menuOpen) {
174 setPanelOpen(false)
175 setMenuOpen(false)
176 return
177 }
178 if (filtered) onClear()
179 }
180 }
181 window.addEventListener('keydown', onKey, true)
182 return () => window.removeEventListener('keydown', onKey, true)
183 }, [filtered, onClear, onCreate, panelOpen, menuOpen])
184
185 const activeCount = view.priorities.length + view.labelIds.length + view.milestoneIds.length + view.assigneeIds.length
186
187 return (
188 <div className="board-filters" ref={bar}>
189 <div className="board-filters-row">
190 <label className="board-search" title={t('issue.filter.searchHint')}>
191 <Search className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
192 <input
193 ref={search}
194 value={text}
195 onChange={(event) => setText(event.target.value)}
196 placeholder={t('issue.filter.searchPlaceholder')}
197 aria-label={t('issue.filter.searchAria')}
198 className="min-w-0 flex-1 bg-transparent text-[12.5px] outline-none placeholder:text-shell-ink-muted"
199 />
200 {text ? (
201 <button
202 type="button"
203 aria-label={t('issue.filter.clearSearch')}
204 onClick={() => setText('')}
205 className="board-search-clear"
206 >
207 <X className="h-3.5 w-3.5" />
208 </button>
209 ) : null}
210 </label>
211
212 <div className="board-segmented" role="group" aria-label={t('issue.filter.statusGroup')}>
213 {STATE_FILTERS.map((item) => (
214 <button
215 key={item.id}
216 type="button"
217 aria-pressed={view.state === item.id}
218 onClick={() => onFilter({ state: item.id as IssueState | 'all' })}
219 >
220 {item.label}
221 </button>
222 ))}
223 </div>
224
225 <FilterMenu
226 label={t('issue.board.filter.sort')}
227 value={view.sort}
228 active={t('issue.filter.sortActive', { label: (SORTS.find((s) => s.id === view.sort) ?? SORTS[0]).label })}
229 options={SORTS.map((sort) => ({ value: sort.id, label: sort.label }))}
230 onChange={(value) => onFilter({ sort: value as IssueSort })}
231 />
232
233 {/* The panel hangs off this trigger, not off the toolbar row: a popover
234 that opens under the search box reads as "search options". */}
235 <div className="board-filter-anchor">
236 <button
237 type="button"
238 className="board-filter-trigger board-filter-open"
239 aria-expanded={panelOpen}
240 aria-haspopup="dialog"
241 onClick={() => {
242 setPanelOpen((open) => !open)
243 setMenuOpen(false)
244 }}
245 >
246 <SlidersHorizontal className="h-3.5 w-3.5 shrink-0" aria-hidden />
247 <span className="board-filter-trigger-value">{t('issue.filter.panelTitle')}</span>
248 {activeCount > 0 ? <span className="board-filter-count">{activeCount}</span> : null}
249 <ChevronDown className="board-filter-chevron h-3 w-3 shrink-0 opacity-70" aria-hidden />
250 </button>
251
252 {panelOpen ? (
253 <div className="board-filter-panel" role="dialog" aria-label={t('issue.filter.panelTitle')}>
254 <FilterSection
255 title={t('issue.board.filter.priority')}
256 options={PRIORITIES.map((meta) => ({ value: meta.id, label: meta.label, color: meta.color }))}
257 selected={view.priorities}
258 onToggle={(value) => onFilter({ priorities: toggleValue(view.priorities, value) as IssuePriority[] })}
259 />
260 <FilterSection
261 title={t('issue.board.filter.label')}
262 options={labels.map((label) => ({ value: label.id, label: label.name, color: label.color }))}
263 selected={view.labelIds}
264 onToggle={(value) => onFilter({ labelIds: toggleValue(view.labelIds, value) })}
265 searchable
266 empty={labelsFailed ? t('issue.filter.labelsFailed') : t('issue.board.manage.noLabels')}
267 />
268 <FilterSection
269 title={t('issue.board.filter.milestone')}
270 options={[
271 ...milestones.map((milestone) => ({
272 value: milestone.id,
273 label: milestone.name,
274 hint: milestone.is_closed ? t('label.issueState.closed') : undefined,
275 })),
276 // `none` is a real filter: the backend matches a NULL milestone.
277 { value: 'none', label: t('issue.filter.noMilestone') },
278 ]}
279 selected={view.milestoneIds}
280 onToggle={(value) => onFilter({ milestoneIds: toggleValue(view.milestoneIds, value) })}
281 searchable
282 />
283 <FilterSection
284 title={t('issue.board.filter.assignee')}
285 options={members.map((user) => ({
286 value: String(user.user_id),
287 label: user.display_name || user.username,
288 hint: user.username ? `@${user.username}` : undefined,
289 }))}
290 selected={view.assigneeIds}
291 onToggle={(value) => onFilter({ assigneeIds: toggleValue(view.assigneeIds, value) })}
292 searchable
293 />
294 <footer className="board-filter-panel-foot">
295 <button type="button" className="board-filter-clear" disabled={!filtered} onClick={onClear}>
296 {t('issue.filter.clearAll')}
297 </button>
298 <button type="button" className="board-filter-done" onClick={() => setPanelOpen(false)}>
299 {t('issue.filter.done')}
300 </button>
301 </footer>
302 </div>
303 ) : null}
304 </div>
305
306 <div className="board-more">
307 <button
308 type="button"
309 className="board-icon-button"
310 aria-label={t('issue.board.more')}
311 title={t('issue.board.more')}
312 aria-expanded={menuOpen}
313 aria-haspopup="menu"
314 onClick={() => {
315 setMenuOpen((open) => !open)
316 setPanelOpen(false)
317 }}
318 >
319 <MoreHorizontal className="h-4 w-4" aria-hidden />
320 </button>
321 {menuOpen ? (
322 <div className="board-more-menu" role="menu">
323 <button
324 type="button"
325 role="menuitem"
326 className="board-more-item"
327 onClick={() => {
328 setMenuOpen(false)
329 onManage()
330 }}
331 >
332 <Settings2 className="h-3.5 w-3.5" aria-hidden />
333 {t('issue.board.settingsAction')}
334 </button>
335 </div>
336 ) : null}
337 </div>
338 </div>
339
340 {/*
341 * Always mounted so the row can grow and collapse over 200ms — inserting
342 * it outright made the list size jump the moment a filter was ticked.
343 */}
344 <div className={`board-filter-summary${filtered ? ' is-on' : ''}`} aria-hidden={!filtered}>
345 <div className="board-filter-summary-inner">
346 <span className="board-filter-total">{t('issue.board.resultCount', { count: total })}</span>
347 <ActiveChips
348 view={view}
349 labelName={(id) => labels.find((label) => label.id === id)?.name}
350 milestoneName={(id) => milestones.find((item) => item.id === id)?.name}
351 memberName={(id) => members.find((user) => String(user.user_id) === id)?.display_name}
352 onFilter={onFilter}
353 />
354 <button type="button" className="board-filter-clear" tabIndex={filtered ? undefined : -1} onClick={onClear}>
355 <X className="h-3 w-3" aria-hidden />
356 {t('issue.filter.clearAll')}
357 </button>
358 </div>
359 </div>
360 </div>
361 )
362}
363
364/* ----------------------------------------------------------- filter panel --- */
365
366interface FilterOption {
367 value: string
368 label: string
369 color?: string
370 hint?: string
371}
372
373/** One dimension of the panel: a checkbox list, searchable when it is long. */
374function FilterSection({
375 title,
376 options,
377 selected,
378 onToggle,
379 searchable = false,
380 empty,
381}: {
382 title: string
383 options: FilterOption[]
384 selected: readonly string[]
385 onToggle: (value: string) => void
386 searchable?: boolean
387 empty?: string
388}) {
389 const { t } = useT()
390 const [query, setQuery] = useState('')
391 const keyword = query.trim().toLowerCase()
392 const rows = keyword ? options.filter((option) => option.label.toLowerCase().includes(keyword)) : options
393 return (
394 <section className="board-filter-section">
395 <header className="board-filter-section-head">
396 <span>{title}</span>
397 {selected.length > 0 ? <span className="board-filter-section-count">{selected.length}</span> : null}
398 </header>
399 {searchable && options.length > 6 ? (
400 <label className="board-filter-section-search">
401 <Search className="h-3 w-3 shrink-0" aria-hidden />
402 <input
403 value={query}
404 onChange={(event) => setQuery(event.target.value)}
405 placeholder={t('issue.filter.searchIn', { title })}
406 aria-label={t('issue.filter.searchIn', { title })}
407 />
408 </label>
409 ) : null}
410 <div className="board-filter-options">
411 {rows.map((option) => {
412 const on = selected.includes(option.value)
413 return (
414 <button
415 key={option.value}
416 type="button"
417 role="checkbox"
418 aria-checked={on}
419 onClick={() => onToggle(option.value)}
420 className="board-filter-check"
421 >
422 <span className="board-filter-check-box" aria-hidden>
423 {on ? <Check className="h-3 w-3" /> : null}
424 </span>
425 {option.color ? (
426 <span
427 className="board-filter-check-dot"
428 style={{ backgroundColor: normalizeColor(option.color) }}
429 aria-hidden
430 />
431 ) : null}
432 <span className="board-filter-check-label">{option.label}</span>
433 {option.hint ? <span className="board-filter-check-hint">{option.hint}</span> : null}
434 </button>
435 )
436 })}
437 {rows.length === 0 ? <p className="board-filter-section-empty">{empty ?? t('issue.board.noOptions')}</p> : null}
438 </div>
439 </section>
440 )
441}
442
443/* ---------------------------------------------------------------- chips --- */
444
445/** Every applied value is removable on its own — the filter state is never hidden. */
446function ActiveChips({
447 view,
448 labelName,
449 milestoneName,
450 memberName,
451 onFilter,
452}: {
453 view: IssueBoardView
454 labelName: (id: string) => string | undefined
455 milestoneName: (id: string) => string | undefined
456 memberName: (id: string) => string | undefined
457 onFilter: (updates: Partial<IssueBoardView>) => void
458}) {
459 const { t } = useT()
460 const chips: Array<{ key: string; text: string; clear: Partial<IssueBoardView> }> = []
461 if (view.state !== 'open')
462 chips.push({
463 key: 'state',
464 text: t('issue.filter.chip.state', {
465 value: STATE_FILTERS.find((s) => s.id === view.state)?.label ?? view.state,
466 }),
467 clear: { state: 'open' },
468 })
469 if (view.q) chips.push({ key: 'q', text: t('issue.filter.chip.query', { value: view.q }), clear: { q: '' } })
470 for (const id of view.priorities) {
471 chips.push({
472 key: `priority-${id}`,
473 text: t('issue.filter.chip.priority', { value: priorityOf(id).label }),
474 clear: { priorities: view.priorities.filter((value) => value !== id) },
475 })
476 }
477 for (const id of view.labelIds) {
478 chips.push({
479 key: `label-${id}`,
480 text: t('issue.filter.chip.label', { value: labelName(id) ?? t('issue.filter.unknownLabel') }),
481 clear: { labelIds: view.labelIds.filter((value) => value !== id) },
482 })
483 }
484 for (const id of view.milestoneIds) {
485 chips.push({
486 key: `milestone-${id}`,
487 text: t('issue.filter.chip.milestone', {
488 value:
489 id === 'none'
490 ? t('issue.filter.noMilestoneShort')
491 : (milestoneName(id) ?? t('issue.filter.unknownMilestone')),
492 }),
493 clear: { milestoneIds: view.milestoneIds.filter((value) => value !== id) },
494 })
495 }
496 for (const id of view.assigneeIds) {
497 chips.push({
498 key: `assignee-${id}`,
499 text: t('issue.filter.chip.assignee', { value: memberName(id) ?? t('common.state.unknownUser') }),
500 clear: { assigneeIds: view.assigneeIds.filter((value) => value !== id) },
501 })
502 }
503 if (view.sort !== 'created') {
504 chips.push({
505 key: 'sort',
506 text: t('issue.filter.chip.sort', { value: (SORTS.find((s) => s.id === view.sort) ?? SORTS[0]).label }),
507 clear: { sort: 'created' },
508 })
509 }
510 if (chips.length === 0) return null
511 return (
512 <>
513 {chips.map((chip) => (
514 <span key={chip.key} className="board-filter-chip">
515 {chip.text}
516 <button
517 type="button"
518 aria-label={t('issue.filter.remove', { text: chip.text })}
519 onClick={() => onFilter(chip.clear)}
520 >
521 <X className="h-3 w-3" aria-hidden />
522 </button>
523 </span>
524 ))}
525 </>
526 )
527}
528
529/* -------------------------------------------------------------- actions --- */
530
531export interface BoardActionsProps {
532 onCreate: () => void
533}
534
535/** Header right slot: the only primary action on the page. */
536export function BoardActions({ onCreate }: BoardActionsProps) {
537 const { t } = useT()
538 return (
539 <button type="button" onClick={onCreate} title={t('issue.board.newHint')} className="board-create-button">
540 <Plus className="h-4 w-4" aria-hidden />
541 <span className="board-create-label">{t('issue.board.new')}</span>
542 </button>
543 )
544}
Added view/app/src/domains/issues/ui/board-manage-modal.tsx view/app/src/domains/issues/ui/board-manage-modal.tsx +504 −0 View view/app/src/domains/issues/ui/board-manage-modal.tsx
@@ -0,0 +1,504 @@
1/**
2 * Board management: rename the 问题集, maintain its labels and milestones, and
3 * delete it. These are the only write paths for `PATCH/DELETE …/boards/{id}`,
4 * `…/labels` and `…/milestones`.
5 *
6 * Rows save individually (not on modal close) so a partial failure cannot lose
7 * the rest of the edits, and every success bumps the page's reload key so the
8 * toolbar filters and the issue pickers see the new option immediately.
9 */
10
11import { Alert } from '@app/components'
12import {
13 type Board,
14 type BoardRef,
15 createLabel,
16 createMilestone,
17 dateApiValue,
18 dateInputValue,
19 deleteBoard,
20 deleteLabel,
21 deleteMilestone,
22 type IssueLabel,
23 isValidBoardPrefix,
24 LABEL_COLORS,
25 MAX_BOARD_PREFIX_LEN,
26 type Milestone,
27 normalizeColor,
28 updateBoard,
29 updateLabel,
30 updateMilestone,
31} from '@app/domains/issues/model'
32import type { MessageKey } from '@app/i18n'
33import { useT } from '@app/i18n'
34import { ApiFailure } from '@app/lib'
35import { Button } from '@app/ui'
36import { ChevronRight, Loader2 } from 'lucide-react'
37import { useEffect, useState } from 'react'
38import type { BoardData } from '../model/use-board-data'
39import { Dialog, smallInputClass } from './dialog'
40import { DangerButton } from './parts'
41
42type Pane = 'general' | 'labels' | 'milestones'
43
44const PANE_KEYS: Record<Pane, MessageKey> = {
45 general: 'issue.board.manage.general',
46 labels: 'issue.board.manage.labels',
47 milestones: 'issue.board.manage.milestones',
48}
49
50const PANES: Pane[] = ['general', 'labels', 'milestones']
51
52export interface BoardManageModalProps {
53 open: boolean
54 boardRef: BoardRef
55 board?: Board
56 data: BoardData
57 onClose: () => void
58 onChanged: () => void
59 /** Board was deleted: the page leaves the board route. */
60 onDeleted: () => void
61}
62
63export function BoardManageModal({
64 open,
65 boardRef,
66 board,
67 data,
68 onClose,
69 onChanged,
70 onDeleted,
71}: BoardManageModalProps) {
72 const { t } = useT()
73 const [pane, setPane] = useState<Pane>('general')
74 const [error, setError] = useState<string | null>(null)
75 const [busy, setBusy] = useState<string | null>(null)
76
77 // Prefer the freshly fetched board: the page loads it, so a rename elsewhere
78 // is reflected here instead of the (possibly stale) sidebar row.
79 const current = data.board.data ?? board
80 const boardId = current?.id
81
82 // Drafts live on the modal (not the pane) because the footer owns the save
83 // button. They reset per board so a half-typed rename never leaks across.
84 const [name, setName] = useState('')
85 const [prefix, setPrefix] = useState('')
86 const [description, setDescription] = useState('')
87 useEffect(() => {
88 if (!open) return
89 setName(current?.name ?? '')
90 setPrefix(current?.prefix ?? '')
91 setDescription(current?.description ?? '')
92 // eslint-disable-next-line react-hooks/exhaustive-deps
93 }, [open, boardId])
94
95 const trimmedName = name.trim()
96 const trimmedPrefix = prefix.trim().toUpperCase()
97 const prefixInvalid = trimmedPrefix.length > 0 && !isValidBoardPrefix(trimmedPrefix)
98 const dirty =
99 Boolean(current) &&
100 (trimmedName !== (current?.name ?? '') ||
101 (trimmedPrefix || null) !== (current?.prefix ?? null) ||
102 (description.trim() || null) !== (current?.description ?? null))
103
104 async function run(key: string, action: () => Promise<unknown>, keepOpen = true) {
105 setBusy(key)
106 setError(null)
107 try {
108 await action()
109 onChanged()
110 if (!keepOpen) onDeleted()
111 return true
112 } catch (err) {
113 setError(err instanceof ApiFailure ? err.message : t('issue.board.manage.runFailed'))
114 return false
115 } finally {
116 setBusy(null)
117 }
118 }
119
120 const save = () =>
121 void run('save-board', () =>
122 updateBoard(boardRef, {
123 name: trimmedName,
124 prefix: trimmedPrefix || null,
125 description: description.trim() || null,
126 }),
127 )
128
129 return (
130 <Dialog
131 open={open}
132 onClose={onClose}
133 title={t('issue.board.manage.title')}
134 width="max-w-[640px]"
135 panelClassName="issue-manage-dialog"
136 header={
137 <div className="issue-form-head">
138 {current?.name ? <span className="issue-form-team">{current.name}</span> : null}
139 <ChevronRight className="h-3.5 w-3.5 shrink-0" aria-hidden />
140 <span className="issue-form-kicker">{t('issue.board.manage.title')}</span>
141 </div>
142 }
143 footer={
144 <div className="issue-manage-footer">
145 {pane === 'general' && current ? (
146 <DangerButton
147 label={t('issue.board.manage.deleteBoard')}
148 busy={busy === 'delete-board'}
149 onConfirm={() => void run('delete-board', () => deleteBoard(boardRef), false)}
150 />
151 ) : null}
152 <div className="issue-manage-footer-actions">
153 <Button variant="secondary" onClick={onClose}>
154 {t('common.action.close')}
155 </Button>
156 {pane === 'general' ? (
157 <Button disabled={busy !== null || !dirty || !trimmedName || prefixInvalid || !current} onClick={save}>
158 {busy === 'save-board' ? t('common.state.busy') : t('common.action.save')}
159 </Button>
160 ) : null}
161 </div>
162 </div>
163 }
164 >
165 <div className="issue-manage-tabs" role="tablist">
166 {PANES.map((id) => (
167 <button
168 key={id}
169 type="button"
170 role="tab"
171 aria-selected={pane === id}
172 data-active={pane === id || undefined}
173 onClick={() => {
174 setPane(id)
175 setError(null)
176 }}
177 className="issue-manage-tab"
178 >
179 {t(PANE_KEYS[id])}
180 </button>
181 ))}
182 </div>
183
184 {error ? <Alert>{error}</Alert> : null}
185 {busy ? (
186 <p className="flex items-center gap-1.5 text-[12px] text-shell-ink-muted">
187 <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden /> {t('issue.board.manage.processing')}
188 </p>
189 ) : null}
190
191 {pane === 'general' ? (
192 <div className="issue-manage-body">
193 <label className="issue-manage-field">
194 <span className="issue-manage-label">{t('issue.board.manage.name')}</span>
195 <input
196 className="issue-manage-input"
197 value={name}
198 onChange={(event) => setName(event.target.value)}
199 maxLength={64}
200 disabled={!current}
201 />
202 </label>
203 <label className="issue-manage-field">
204 <span className="issue-manage-label">{t('issue.board.manage.prefix')}</span>
205 <input
206 className={`issue-manage-input issue-manage-prefix${prefixInvalid ? ' is-invalid' : ''}`}
207 value={prefix}
208 onChange={(event) => setPrefix(event.target.value.toUpperCase())}
209 placeholder={t('issue.board.manage.prefixPlaceholder')}
210 maxLength={MAX_BOARD_PREFIX_LEN}
211 spellCheck={false}
212 autoComplete="off"
213 disabled={!current}
214 />
215 {prefixInvalid ? (
216 <span className="issue-manage-error">{t('issue.board.manage.prefixInvalid')}</span>
217 ) : (
218 <span className="issue-manage-hint">{t('issue.board.manage.prefixHint')}</span>
219 )}
220 </label>
221 <label className="issue-manage-field">
222 <span className="issue-manage-label">{t('issue.board.manage.descriptionField')}</span>
223 <textarea
224 value={description}
225 onChange={(event) => setDescription(event.target.value)}
226 rows={3}
227 disabled={!current}
228 className="issue-manage-input issue-manage-textarea"
229 />
230 </label>
231 <p className="issue-manage-stats">
232 {t('issue.board.manage.stats', {
233 open: current?.open_issues_count ?? 0,
234 closed: current?.closed_issues_count ?? 0,
235 })}
236 </p>
237 </div>
238 ) : null}
239
240 {pane === 'labels' ? (
241 <LabelsPane
242 boardRef={boardRef}
243 labels={data.labels.data?.items ?? []}
244 busy={busy}
245 onRun={run}
246 failed={Boolean(data.labels.error)}
247 />
248 ) : null}
249
250 {pane === 'milestones' ? (
251 <MilestonesPane
252 boardRef={boardRef}
253 milestones={data.milestones.data?.items ?? []}
254 busy={busy}
255 onRun={run}
256 failed={Boolean(data.milestones.error)}
257 />
258 ) : null}
259 </Dialog>
260 )
261}
262
263type Runner = (key: string, action: () => Promise<unknown>, keepOpen?: boolean) => Promise<boolean>
264
265function LabelsPane({
266 boardRef,
267 labels,
268 busy,
269 onRun,
270 failed,
271}: {
272 boardRef: BoardRef
273 labels: IssueLabel[]
274 busy: string | null
275 onRun: Runner
276 failed: boolean
277}) {
278 const [draftName, setDraftName] = useState('')
279 const { t } = useT()
280 const [draftColor, setDraftColor] = useState<string>(LABEL_COLORS[5])
281
282 async function add() {
283 const trimmed = draftName.trim()
284 if (!trimmed) return
285 const ok = await onRun('new-label', () => createLabel(boardRef, { name: trimmed, color: draftColor }))
286 if (ok) setDraftName('')
287 }
288
289 return (
290 <div className="space-y-2">
291 {failed ? <p className="text-[12px] text-[#b42318]">{t('issue.board.manage.labelsFailed')}</p> : null}
292 {labels.length === 0 && !failed ? (
293 <p className="text-[12.5px] text-shell-ink-muted">{t('issue.board.manage.noLabels')}</p>
294 ) : null}
295 {labels.map((label) => (
296 <LabelRow key={label.id} boardRef={boardRef} label={label} busy={busy === `label-${label.id}`} onRun={onRun} />
297 ))}
298 <div className="flex items-center gap-2 border-t-2 border-shell-hairline pt-3">
299 <input
300 className={smallInputClass}
301 value={draftName}
302 onChange={(event) => setDraftName(event.target.value)}
303 onKeyDown={(event) => {
304 if (event.key === 'Enter') void add()
305 }}
306 placeholder={t('issue.board.manage.newLabel')}
307 aria-label={t('issue.board.manage.newLabel')}
308 name="new-label-name"
309 maxLength={48}
310 />
311 <div className="flex shrink-0 items-center gap-1">
312 {LABEL_COLORS.map((color) => (
313 <button
314 key={color}
315 type="button"
316 aria-label={t('issue.board.manage.useColor', { color })}
317 aria-pressed={draftColor === color}
318 onClick={() => setDraftColor(color)}
319 className={`h-5 w-5 rounded-full transition-transform ${draftColor === color ? 'scale-110 ring-2 ring-shell-ink' : ''}`}
320 style={{ backgroundColor: normalizeColor(color) }}
321 />
322 ))}
323 </div>
324 <button
325 type="button"
326 disabled={!draftName.trim() || busy !== null}
327 onClick={() => void add()}
328 className="ml-auto shrink-0 rounded-md bg-shell-action px-3 py-1 text-[12.5px] font-semibold text-white transition-[filter] hover:brightness-95 disabled:opacity-50"
329 >
330 {t('issue.board.manage.add')}
331 </button>
332 </div>
333 </div>
334 )
335}
336
337function LabelRow({
338 boardRef,
339 label,
340 busy,
341 onRun,
342}: {
343 boardRef: BoardRef
344 label: IssueLabel
345 busy: boolean
346 onRun: Runner
347}) {
348 const { t } = useT()
349 const [name, setName] = useState(label.name)
350 const [color, setColor] = useState(normalizeColor(label.color))
351 const dirty = name.trim() !== label.name || color !== normalizeColor(label.color)
352
353 return (
354 <div className="flex items-center gap-2">
355 <button
356 type="button"
357 aria-label={t('issue.board.manage.selectColor')}
358 onClick={() =>
359 setColor(LABEL_COLORS[(LABEL_COLORS.indexOf(color as never) + 1) % LABEL_COLORS.length] ?? LABEL_COLORS[0])
360 }
361 className="h-5 w-5 shrink-0 rounded-full ring-1 ring-shell-hairline"
362 style={{ backgroundColor: color }}
363 />
364 <input
365 value={name}
366 onChange={(event) => setName(event.target.value)}
367 aria-label={t('issue.board.manage.labelName')}
368 className={smallInputClass}
369 maxLength={48}
370 />
371 <button
372 type="button"
373 disabled={!dirty || busy}
374 onClick={() =>
375 void onRun(`label-${label.id}`, () => updateLabel(boardRef, label.id, { name: name.trim(), color }))
376 }
377 className="shrink-0 rounded px-2 py-1 text-[12px] text-shell-ink-muted transition-colors hover:bg-shell-field hover:text-shell-ink disabled:opacity-40"
378 >
379 {busy ? t('issue.board.manage.saving') : dirty ? t('common.action.save') : t('issue.board.manage.saved')}
380 </button>
381 <DangerButton
382 label={t('common.action.delete')}
383 busy={busy}
384 onConfirm={() => void onRun(`label-${label.id}`, () => deleteLabel(boardRef, label.id))}
385 />
386 </div>
387 )
388}
389
390function MilestonesPane({
391 boardRef,
392 milestones,
393 busy,
394 onRun,
395 failed,
396}: {
397 boardRef: BoardRef
398 milestones: Milestone[]
399 busy: string | null
400 onRun: Runner
401 failed: boolean
402}) {
403 const { t } = useT()
404 const [name, setName] = useState('')
405 const [due, setDue] = useState('')
406
407 async function add() {
408 const trimmed = name.trim()
409 if (!trimmed) return
410 const ok = await onRun('new-milestone', () =>
411 createMilestone(boardRef, { name: trimmed, due_at: dateApiValue(due) }),
412 )
413 if (ok) {
414 setName('')
415 setDue('')
416 }
417 }
418
419 return (
420 <div className="space-y-2">
421 {failed ? <p className="text-[12px] text-[#b42318]">{t('issue.board.manage.milestonesFailed')}</p> : null}
422 {milestones.length === 0 && !failed ? (
423 <p className="text-[12.5px] text-shell-ink-muted">{t('issue.board.manage.noMilestones')}</p>
424 ) : null}
425 {milestones.map((milestone) => (
426 <div key={milestone.id} className={`flex items-center gap-2 ${milestone.is_closed ? 'opacity-60' : ''}`}>
427 <input
428 value={milestone.name}
429 onChange={(event) =>
430 void onRun(`milestone-${milestone.id}`, () =>
431 updateMilestone(boardRef, milestone.id, { name: event.target.value }),
432 )
433 }
434 aria-label={t('issue.board.manage.milestoneName')}
435 name="milestone-name"
436 className={smallInputClass}
437 maxLength={64}
438 />
439 <label className="flex shrink-0 items-center gap-1 text-[11.5px] text-shell-ink-muted">
440 {t('issue.board.manage.due')}
441 <input
442 type="date"
443 value={dateInputValue(milestone.due_at)}
444 onChange={(event) =>
445 void onRun(`milestone-${milestone.id}`, () =>
446 updateMilestone(boardRef, milestone.id, { due_at: dateApiValue(event.target.value) }),
447 )
448 }
449 className="h-7 rounded-md border-2 border-shell-hairline bg-shell-field px-1 text-[12px] outline-none"
450 />
451 </label>
452 <span className="shrink-0 text-[11.5px] text-shell-ink-muted">
453 {milestone.issues_count - milestone.closed_issues_count}/{milestone.issues_count}
454 </span>
455 <button
456 type="button"
457 disabled={busy !== null}
458 onClick={() =>
459 void onRun(`milestone-${milestone.id}`, () =>
460 updateMilestone(boardRef, milestone.id, { is_closed: !milestone.is_closed }),
461 )
462 }
463 className="shrink-0 rounded px-2 py-1 text-[12px] text-shell-ink-muted transition-colors hover:bg-shell-field hover:text-shell-ink"
464 >
465 {milestone.is_closed ? t('issue.board.reopen') : t('issue.board.manage.close')}
466 </button>
467 <DangerButton
468 label={t('common.action.delete')}
469 busy={busy === `milestone-${milestone.id}`}
470 onConfirm={() => void onRun(`milestone-${milestone.id}`, () => deleteMilestone(boardRef, milestone.id))}
471 />
472 </div>
473 ))}
474 <div className="flex items-center gap-2 border-t-2 border-shell-hairline pt-3">
475 <input
476 className={smallInputClass}
477 value={name}
478 onChange={(event) => setName(event.target.value)}
479 onKeyDown={(event) => {
480 if (event.key === 'Enter') void add()
481 }}
482 placeholder={t('issue.board.manage.newMilestone')}
483 aria-label={t('issue.board.manage.newMilestone')}
484 maxLength={64}
485 />
486 <input
487 type="date"
488 value={due}
489 onChange={(event) => setDue(event.target.value)}
490 aria-label={t('issue.board.manage.milestoneDue')}
491 className="h-7 shrink-0 rounded-md border-2 border-shell-hairline bg-shell-field px-2 text-[12px] outline-none"
492 />
493 <button
494 type="button"
495 disabled={!name.trim() || busy !== null}
496 onClick={() => void add()}
497 className="ml-auto shrink-0 rounded-md bg-shell-action px-3 py-1 text-[12.5px] font-semibold text-white transition-[filter] hover:brightness-95 disabled:opacity-50"
498 >
499 {t('issue.board.manage.add')}
500 </button>
501 </div>
502 </div>
503 )
504}
Added view/app/src/domains/issues/ui/board-polish.css view/app/src/domains/issues/ui/board-polish.css +2483 −0 View view/app/src/domains/issues/ui/board-polish.css
@@ -0,0 +1,2483 @@
1/* Issue workspace: comfortable controls, quiet surfaces and legible state. */
2.board-toolbar:has(.board-filters) {
3 align-items: flex-start;
4 gap: 8px;
5 padding-block: 10px;
6 border-bottom: 1px solid var(--shell-hairline);
7 background: var(--shell-surface);
8}
9.board-filters {
10 display: flex;
11 min-width: 0;
12 flex: 1 1 100%;
13 flex-direction: column;
14 gap: 8px;
15}
16.board-filters-row {
17 position: relative;
18 display: flex;
19 min-width: 0;
20 flex-wrap: wrap;
21 align-items: center;
22 gap: 8px;
23}
24.board-toolbar-actions {
25 padding-top: 1px;
26 gap: 8px;
27}
28.board-search {
29 display: flex;
30 height: 32px;
31 width: min(360px, 40vw);
32 min-width: 180px;
33 flex: 0 0 auto;
34 align-items: center;
35 gap: 7px;
36 padding-inline: 9px;
37 border: 1px solid var(--shell-hairline);
38 border-radius: 8px;
39 background: var(--shell-field);
40 transition:
41 border-color 150ms,
42 box-shadow 150ms;
43}
44.board-search:focus-within {
45 border-color: var(--shell-tile);
46 background: var(--shell-surface);
47 box-shadow: 0 0 0 3px color-mix(in srgb, var(--shell-focus) 20%, transparent);
48}
49.board-search input {
50 font-size: 13px;
51}
52.board-search-clear {
53 display: grid;
54 place-items: center;
55 width: 22px;
56 height: 22px;
57 border-radius: 5px;
58}
59.board-search-clear:hover {
60 background: var(--shell-active);
61}
62.board-segmented {
63 display: inline-flex;
64 min-height: 32px;
65 align-items: center;
66 padding: 3px;
67 border-radius: 8px;
68 background: var(--shell-field);
69 gap: 2px;
70}
71.board-segmented > button {
72 min-height: 26px;
73 flex-shrink: 0;
74 padding-inline: 9px;
75 border-radius: 6px;
76 color: var(--shell-ink-muted);
77 font-size: 12px;
78 white-space: nowrap;
79 transition:
80 background 140ms,
81 color 140ms,
82 box-shadow 140ms;
83}
84.board-segmented > button:hover {
85 color: var(--shell-ink);
86}
87.board-segmented > button[aria-pressed="true"] {
88 color: var(--shell-ink-muted);
89 background: var(--shell-surface);
90 box-shadow: 0 1px 3px rgb(20 30 60 / 7%);
91}
92.board-filter-trigger {
93 display: inline-flex;
94 height: 32px;
95 flex-shrink: 0;
96 align-items: center;
97 padding-inline: 10px;
98 gap: 6px;
99 border: 1px solid var(--shell-hairline);
100 border-radius: 8px;
101 background: var(--shell-surface);
102 font-size: 12.5px;
103 white-space: nowrap;
104}
105.board-filter-trigger[aria-expanded="true"] {
106 border-color: var(--shell-tile);
107 color: var(--shell-ink-muted);
108 background: var(--shell-field);
109}
110.board-filter-trigger:hover {
111 border-color: var(--shell-tile);
112}
113.board-filter-popover {
114 margin-top: 7px;
115 padding: 6px;
116 max-width: calc(100vw - 40px);
117 border-width: 1px;
118 border-radius: 12px;
119 box-shadow: 0 12px 36px rgb(20 30 60 / 14%);
120}
121.board-filter-option {
122 min-height: 36px;
123 border-radius: 7px;
124 gap: 8px;
125}
126.board-filter-option[aria-selected="true"] {
127 background: var(--shell-field);
128 color: var(--shell-ink-muted);
129}
130.board-filter-option:focus-visible {
131 outline-offset: -2px;
132}
133.board-view-tabs {
134 display: inline-flex;
135 gap: 2px;
136 padding: 2px;
137 border-radius: 8px;
138 background: var(--shell-field);
139}
140.board-view-tab {
141 display: inline-flex;
142 height: 26px;
143 flex-shrink: 0;
144 align-items: center;
145 gap: 5px;
146 padding-inline: 10px;
147 border-radius: 6px;
148 color: var(--shell-ink-muted);
149 font-size: 12.5px;
150 white-space: nowrap;
151 transition:
152 background 140ms,
153 color 140ms,
154 box-shadow 140ms;
155}
156.board-view-tab:hover {
157 color: var(--shell-ink);
158}
159.board-view-tab[aria-pressed="true"] {
160 background: var(--shell-surface);
161 color: var(--shell-ink);
162 font-weight: 600;
163 box-shadow: 0 1px 2px rgb(20 30 60 / 12%);
164}
165.board-filter-count {
166 display: inline-grid;
167 min-width: 16px;
168 height: 16px;
169 place-items: center;
170 padding-inline: 4px;
171 border-radius: 999px;
172 background: var(--shell-action);
173 color: #fff;
174 font-size: 10.5px;
175 font-weight: 600;
176 font-variant-numeric: tabular-nums;
177}
178.board-icon-button {
179 display: grid;
180 width: 32px;
181 height: 32px;
182 place-items: center;
183 border: 1px solid var(--shell-hairline);
184 border-radius: 8px;
185 background: var(--shell-surface);
186 color: var(--shell-ink-muted);
187 transition:
188 background 140ms,
189 color 140ms,
190 border-color 140ms;
191}
192.board-icon-button:hover,
193.board-icon-button[aria-expanded="true"] {
194 border-color: var(--shell-tile);
195 background: var(--shell-field);
196 color: var(--shell-ink);
197}
198.board-more {
199 position: relative;
200}
201.board-more-menu {
202 position: absolute;
203 right: 0;
204 z-index: 30;
205 margin-top: 6px;
206 min-width: 168px;
207 padding: 4px;
208 border: 1px solid var(--shell-hairline);
209 border-radius: 10px;
210 background: var(--shell-surface);
211 box-shadow: 0 12px 36px rgb(20 30 60 / 14%);
212}
213.board-more-item {
214 display: flex;
215 width: 100%;
216 min-height: 32px;
217 align-items: center;
218 gap: 8px;
219 padding-inline: 8px;
220 border-radius: 7px;
221 color: var(--shell-ink);
222 font-size: 12.5px;
223 transition: background 140ms;
224}
225.board-more-item:hover {
226 background: var(--shell-field);
227}
228/*
229 * 筛选 panel: one popover for the four low-frequency dimensions. Multi-select
230 * rows apply immediately, and each section carries its own search once the
231 * option list is long enough to need one.
232 */
233.board-filter-anchor {
234 position: relative;
235 flex-shrink: 0;
236}
237/* Anchored to the 筛选 trigger and opened leftwards, so it reads as that
238 button's panel rather than as an extension of the search field. */
239.board-filter-panel {
240 position: absolute;
241 top: calc(100% + 6px);
242 right: 0;
243 z-index: 30;
244 display: flex;
245 width: 300px;
246 max-height: min(560px, calc(100dvh - 220px));
247 flex-direction: column;
248 overflow-y: auto;
249 padding: 8px;
250 border: 1px solid var(--shell-hairline);
251 border-radius: 12px;
252 background: var(--shell-surface);
253 box-shadow: 0 18px 48px rgb(20 30 60 / 16%);
254}
255.board-filter-section {
256 display: flex;
257 flex-direction: column;
258 gap: 4px;
259 padding-block: 5px;
260}
261.board-filter-section + .board-filter-section {
262 border-top: 1px solid var(--shell-hairline);
263}
264.board-filter-section-head {
265 display: flex;
266 align-items: center;
267 gap: 6px;
268 padding: 2px 8px 3px;
269 color: var(--shell-ink-muted);
270 font-size: 11.5px;
271 font-weight: 600;
272 letter-spacing: 0.02em;
273}
274.board-filter-section-count {
275 display: inline-grid;
276 min-width: 15px;
277 height: 15px;
278 place-items: center;
279 border-radius: 999px;
280 background: var(--shell-active);
281 color: var(--shell-ink);
282 font-size: 10px;
283}
284.board-filter-section-search {
285 display: flex;
286 height: 28px;
287 align-items: center;
288 gap: 6px;
289 margin: 0 6px 4px;
290 padding-inline: 8px;
291 border: 1px solid var(--shell-hairline);
292 border-radius: 7px;
293 background: var(--shell-field);
294 color: var(--shell-ink-muted);
295}
296.board-filter-section-search input {
297 min-width: 0;
298 flex: 1;
299 background: transparent;
300 font-size: 12px;
301 outline: none;
302}
303.board-filter-options {
304 display: flex;
305 max-height: 176px;
306 flex-direction: column;
307 gap: 1px;
308 overflow-y: auto;
309}
310.board-filter-check {
311 display: flex;
312 min-height: 30px;
313 align-items: center;
314 gap: 8px;
315 padding-inline: 8px;
316 border-radius: 7px;
317 color: var(--shell-ink);
318 font-size: 12.5px;
319 text-align: left;
320 transition: background 120ms;
321}
322.board-filter-check:hover {
323 background: var(--shell-field);
324}
325.board-filter-check-box {
326 display: grid;
327 width: 15px;
328 height: 15px;
329 flex-shrink: 0;
330 place-items: center;
331 border: 1px solid var(--shell-hairline);
332 border-radius: 4px;
333 background: var(--shell-surface);
334 color: #fff;
335 transition:
336 background 120ms,
337 border-color 120ms;
338}
339.board-filter-check[aria-checked="true"] .board-filter-check-box {
340 border-color: var(--shell-action);
341 background: var(--shell-action);
342}
343.board-filter-check-dot {
344 width: 8px;
345 height: 8px;
346 flex-shrink: 0;
347 border-radius: 999px;
348}
349.board-filter-check-label {
350 min-width: 0;
351 flex: 1;
352 overflow: hidden;
353 text-overflow: ellipsis;
354 white-space: nowrap;
355}
356.board-filter-check-hint {
357 flex-shrink: 0;
358 color: var(--shell-ink-muted);
359 font-size: 11px;
360}
361.board-filter-section-empty {
362 padding: 6px 8px;
363 color: var(--shell-ink-muted);
364 font-size: 11.5px;
365}
366.board-filter-panel-foot {
367 display: flex;
368 align-items: center;
369 gap: 8px;
370 padding: 7px 4px 2px;
371 border-top: 1px solid var(--shell-hairline);
372}
373.board-filter-done {
374 height: 28px;
375 margin-left: auto;
376 padding-inline: 12px;
377 border-radius: 7px;
378 background: var(--shell-action);
379 color: #fff;
380 font-size: 12px;
381 font-weight: 600;
382}
383.board-filter-done:hover {
384 filter: brightness(1.08);
385}
386/*
387 * The summary grows out of the toolbar instead of being inserted into it: a
388 * `0fr → 1fr` grid row is the one height transition that works without
389 * measuring, so the list below slides down rather than jumping.
390 */
391.board-filter-summary {
392 display: grid;
393 grid-template-rows: 0fr;
394 opacity: 0;
395 transition:
396 grid-template-rows 200ms ease,
397 opacity 200ms ease;
398}
399.board-filter-summary.is-on {
400 grid-template-rows: 1fr;
401 opacity: 1;
402}
403.board-filter-summary-inner {
404 display: flex;
405 min-height: 0;
406 min-width: 0;
407 flex-wrap: wrap;
408 align-items: center;
409 gap: 6px;
410 overflow: hidden;
411}
412.board-filter-total {
413 color: var(--shell-ink-muted);
414 font-size: 11.5px;
415}
416.board-filter-chip {
417 display: inline-flex;
418 align-items: center;
419 gap: 5px;
420 padding: 3px 6px 3px 9px;
421 border-radius: 999px;
422 background: var(--shell-field);
423 color: var(--shell-ink);
424 font-size: 11.5px;
425}
426.board-filter-chip > button {
427 display: grid;
428 width: 16px;
429 height: 16px;
430 place-items: center;
431 border-radius: 50%;
432 color: var(--shell-ink-muted);
433}
434.board-filter-chip > button:hover {
435 background: var(--shell-active);
436 color: var(--shell-ink);
437}
438.board-filter-clear {
439 display: inline-flex;
440 align-items: center;
441 gap: 4px;
442 padding: 3px 4px;
443 border-radius: 6px;
444 color: var(--shell-ink-muted);
445 font-size: 11.5px;
446 transition: color 140ms;
447}
448.board-filter-clear:hover:not(:disabled) {
449 color: var(--shell-ink);
450}
451.board-filter-clear:disabled {
452 opacity: 0.5;
453}
454
455/*
456 * Responsive ladder for the board chrome:
457 * <1280px the toolbar keeps search + status + 排序 + 筛选, everything else
458 * already lives in the panel, so only the gaps tighten
459 * <900px the search takes the full row instead of shrinking forever
460 * <768px 筛选 becomes a bottom sheet and 新建工单 drops to its icon
461 */
462@media (max-width: 1280px) {
463 .board-filters-row {
464 gap: 6px;
465 }
466 .board-search {
467 width: min(300px, 34vw);
468 }
469}
470@media (max-width: 900px) {
471 .board-search {
472 width: 100%;
473 order: -1;
474 }
475}
476@media (max-width: 768px) {
477 .board-toolbar:has(.board-filters) {
478 padding-block: 8px;
479 }
480 .board-filter-panel {
481 position: fixed;
482 inset: auto 0 0 0;
483 width: auto;
484 max-height: 72dvh;
485 border-radius: 16px 16px 0 0;
486 border-bottom: 0;
487 box-shadow: 0 -18px 48px rgb(20 30 60 / 22%);
488 }
489 .board-filter-options {
490 max-height: none;
491 }
492 .board-create-label {
493 display: none;
494 }
495 .board-create-button {
496 padding-inline: 10px;
497 }
498}
499.board-create-button {
500 display: inline-flex;
501 height: 32px;
502 min-height: 32px;
503 align-items: center;
504 gap: 6px;
505 padding-inline: 12px;
506 border-radius: 8px;
507 background: var(--shell-action);
508 color: #fff;
509 font-size: 13px;
510 font-weight: 600;
511 transition:
512 filter 140ms,
513 transform 140ms;
514}
515.board-create-button {
516 box-shadow: 0 3px 8px rgb(35 39 43 / 5%);
517}
518.board-create-button:hover {
519 transform: translateY(-1px);
520}
521.board-canvas:has(.issue-board-body),
522.board-canvas:has(.issue-board-empty) {
523 padding-top: 20px;
524}
525.issue-board-body {
526 gap: 16px;
527}
528.issue-board-columns {
529 gap: 16px;
530}
531.issue-board-column {
532 --priority-tone: #718096;
533 width: 284px;
534 padding: 12px;
535 gap: 14px;
536 border: 1px solid color-mix(in srgb, var(--shell-hairline) 76%, transparent);
537 border-radius: 14px;
538 background: color-mix(in srgb, var(--priority-tone) 4%, var(--shell-board));
539 transition:
540 background 180ms,
541 border-color 180ms;
542}
543[data-priority="urgent"] {
544 --priority-tone: #cb5363;
545}
546[data-priority="high"] {
547 --priority-tone: #c67536;
548}
549[data-priority="medium"] {
550 --priority-tone: #9b801b;
551}
552[data-priority="low"] {
553 --priority-tone: #4c7bc2;
554}
555[data-priority="none"] {
556 --priority-tone: #68758a;
557}
558.issue-board-column[data-drag-over] {
559 background: var(--shell-active);
560 border-color: var(--shell-ink-muted);
561}
562.issue-board-column-header {
563 height: 30px;
564 padding: 0 2px;
565 gap: 8px;
566}
567.issue-board-column-header > span:first-child {
568 width: 8px;
569 height: 8px;
570 background: var(--priority-tone) !important;
571 box-shadow: 0 0 0 4px color-mix(in srgb, var(--priority-tone) 9%, transparent);
572}
573.issue-board-column-header > span:nth-child(2) {
574 font-size: 13px;
575 letter-spacing: 0.02em;
576}
577.issue-board-count {
578 display: inline-flex;
579 justify-content: center;
580 align-items: center;
581 min-width: 23px;
582 height: 23px;
583 padding-inline: 5px;
584 border: 1px solid var(--shell-hairline);
585 border-radius: 7px;
586 background: var(--shell-surface);
587 font-size: 11px;
588 font-variant-numeric: tabular-nums;
589}
590.issue-board-add {
591 display: grid;
592 width: 28px;
593 height: 28px;
594 place-items: center;
595 border-radius: 7px;
596}
597.issue-board-column > div {
598 padding: 1px 1px 6px;
599}
600.issue-board-column > div > * + * {
601 margin-top: 10px;
602}
603.issue-board-card {
604 padding: 15px 14px 12px;
605 border: 1px solid var(--shell-hairline);
606 border-radius: 11px;
607 box-shadow: 0 2px 4px rgb(20 30 60 / 3%);
608 transition:
609 border-color 150ms,
610 box-shadow 150ms,
611 transform 150ms,
612 opacity 150ms;
613}
614.issue-board-card:hover {
615 transform: translateY(-2px);
616 border-color: var(--shell-tile);
617 box-shadow: 0 6px 18px rgb(20 30 60 / 7%);
618}
619.issue-board-card[data-moving] {
620 opacity: 0.45;
621}
622.issue-board-card > div:first-child {
623 flex-wrap: wrap;
624 row-gap: 8px;
625}
626.issue-board-card-title {
627 flex-basis: 100%;
628 font-size: 13px;
629 font-weight: 550;
630 line-height: 1.7;
631}
632.issue-board-card-title .gitdata-markdown,
633.issue-list-title .gitdata-markdown {
634 color: inherit;
635 font-size: inherit;
636 font-weight: inherit;
637 line-height: inherit;
638 letter-spacing: inherit;
639}
640.issue-board-card-meta {
641 flex-wrap: wrap;
642 gap: 7px;
643 margin-top: 14px;
644 padding-top: 10px;
645 border-top: 1px solid color-mix(in srgb, var(--shell-hairline) 65%, transparent);
646}
647.issue-board-card-meta > span[title] {
648 max-width: 95px;
649}
650.issue-priority-badge {
651 padding: 3px 8px;
652 gap: 5px;
653 color: color-mix(in srgb, var(--priority-tone) 85%, var(--shell-ink));
654 background: color-mix(in srgb, var(--priority-tone) 12%, var(--shell-surface));
655 border-radius: 5px;
656 font-size: 10px;
657 line-height: 1.5;
658 font-weight: 550;
659}
660.issue-priority-badge::before {
661 content: "";
662 width: 5px;
663 height: 5px;
664 border-radius: 50%;
665 background: var(--priority-tone);
666}
667.issue-state-badge {
668 padding: 4px 8px;
669 border-radius: 6px;
670 color: #278361;
671 background: color-mix(in srgb, #34a179 12%, var(--shell-surface));
672 font-weight: 550;
673 white-space: nowrap;
674}
675.issue-state-badge[data-state="closed"] {
676 color: var(--shell-ink-muted);
677 background: var(--shell-field);
678}
679.issue-board-column-empty {
680 display: flex;
681 flex-direction: column;
682 align-items: center;
683 justify-content: center;
684 width: 100%;
685 min-height: 134px;
686 gap: 9px;
687 border: 1px dashed color-mix(in srgb, var(--priority-tone) 22%, var(--shell-hairline));
688 border-radius: 10px;
689 color: var(--shell-ink-muted);
690 font-size: 12px;
691 transition:
692 background 150ms,
693 color 150ms,
694 border-color 150ms;
695}
696.issue-board-column-empty small {
697 font-size: 10px;
698 opacity: 0.85;
699}
700.issue-board-column-empty:hover {
701 border-color: var(--shell-ink-muted);
702 color: var(--shell-ink-muted);
703 background: var(--shell-field);
704}
705.issue-board-empty {
706 margin: 0 auto;
707 align-self: flex-start;
708 min-height: 360px;
709 justify-content: center;
710 padding: 56px 24px;
711 border: 1px solid var(--shell-hairline);
712 border-radius: 18px;
713 background: radial-gradient(ellipse at top, var(--shell-field), var(--shell-surface) 65%);
714}
715.issue-board-empty-icon {
716 display: grid;
717 width: 64px;
718 height: 64px;
719 margin-bottom: 14px;
720 place-items: center;
721 border: 1px solid var(--shell-tile);
722 border-radius: 20px;
723 background: var(--shell-field);
724 color: var(--shell-ink-muted);
725 transform: rotate(-5deg);
726}
727.issue-board-empty > p:first-of-type {
728 font-size: 18px;
729 font-weight: 600;
730}
731.issue-board-empty > p:nth-of-type(2) {
732 margin-top: 2px;
733 font-size: 13px;
734 line-height: 1.8;
735}
736.issue-board-empty > button {
737 min-height: 38px;
738 margin-top: 17px;
739 border-radius: 9px;
740 padding-inline: 15px;
741}
742/*
743 * List view: Linear-style rows. No header, no cell borders, no card — the row
744 * itself is the only container, and hover paints it. Priority is a mark you
745 * can click to change; the trailing facts (milestone / assignees / updated)
746 * stay muted so the title keeps the ink.
747 */
748.issue-list {
749 display: flex;
750 min-width: 0;
751 flex-direction: column;
752 gap: 1px;
753 padding: 6px 8px;
754 border-radius: 12px;
755 background: var(--shell-surface);
756}
757/* Rows settle in rather than snapping when a filter changes the result set.
758 No `fill-mode`, so the hover transforms below keep working after the fade. */
759.issue-list-row,
760.issue-board-card {
761 animation: issue-row-in 200ms ease;
762}
763@keyframes issue-row-in {
764 from {
765 opacity: 0;
766 transform: translateY(-3px);
767 }
768 to {
769 opacity: 1;
770 transform: none;
771 }
772}
773.issue-list-row {
774 display: flex;
775 min-height: 40px;
776 min-width: 0;
777 cursor: pointer;
778 align-items: center;
779 gap: 8px;
780 padding: 0 8px;
781 border-radius: 8px;
782 transition: background 120ms ease;
783}
784/* The list sits on a white surface, so the hover steps down to the canvas
785 tone instead of vanishing into the page. */
786.issue-list-row:hover,
787.issue-list-row:focus-within {
788 background: var(--shell-field);
789}
790.issue-list-title {
791 min-width: 0;
792 flex: 1;
793 overflow: hidden;
794 color: var(--shell-ink);
795 font-size: 13px;
796 font-weight: 500;
797 line-height: 1.6;
798 text-align: left;
799 text-overflow: ellipsis;
800 white-space: nowrap;
801}
802.issue-list-title:hover {
803 color: color-mix(in srgb, var(--shell-ink) 74%, var(--shell-ink-muted));
804}
805.issue-list-key {
806 flex-shrink: 0;
807 white-space: nowrap;
808 color: var(--shell-ink);
809 font-size: 12.5px;
810 font-weight: 550;
811 letter-spacing: 0.01em;
812}
813.issue-list-state {
814 display: grid;
815 width: 16px;
816 flex-shrink: 0;
817 place-items: center;
818 color: var(--shell-ink-muted);
819}
820.issue-list-state[data-state="open"] {
821 color: #3ba55d;
822}
823.issue-list-meta {
824 display: flex;
825 flex-shrink: 0;
826 align-items: center;
827 gap: 10px;
828}
829.issue-list-chip {
830 display: inline-flex;
831 max-width: 190px;
832 align-items: center;
833 gap: 6px;
834 padding: 2px 9px;
835 border: 1px solid var(--shell-hairline);
836 border-radius: 999px;
837 background: var(--shell-surface);
838 color: var(--shell-ink-muted);
839 font-size: 11.5px;
840 line-height: 1.5;
841}
842.issue-list-dot {
843 width: 7px;
844 height: 7px;
845 flex-shrink: 0;
846 border-radius: 999px;
847}
848.issue-list-chip-text {
849 overflow: hidden;
850 text-overflow: ellipsis;
851 white-space: nowrap;
852}
853.issue-list-date {
854 min-width: 76px;
855 flex-shrink: 0;
856 text-align: right;
857 color: var(--shell-ink-muted);
858 font-size: 12px;
859 font-variant-numeric: tabular-nums;
860}
861.issue-priority-glyph {
862 display: grid;
863 width: 18px;
864 height: 18px;
865 place-items: center;
866 color: var(--shell-ink-muted);
867}
868.issue-priority-glyph svg {
869 display: block;
870}
871.issue-priority-glyph rect {
872 fill: none;
873 stroke: currentColor;
874 stroke-width: 1.1;
875 opacity: 0.45;
876}
877.issue-priority-glyph rect.is-on {
878 fill: currentColor;
879 stroke: none;
880 opacity: 0.95;
881}
882.issue-priority-glyph[data-priority="urgent"] {
883 color: #e5484d;
884}
885.issue-priority-glyph[data-priority="high"] {
886 color: color-mix(in srgb, var(--shell-ink) 82%, var(--shell-ink-muted));
887}
888/* Square hit area: an icon-only trigger must not be a tall letterbox. */
889.board-filter-trigger-icon {
890 display: grid;
891 width: 26px;
892 height: 26px;
893 place-items: center;
894 padding: 0;
895 border-radius: 7px;
896 background: transparent;
897 color: var(--shell-ink-muted);
898}
899.board-filter-trigger-icon:hover,
900.board-filter-trigger-icon[aria-expanded="true"] {
901 background: color-mix(in srgb, var(--shell-ink) 8%, transparent);
902 color: var(--shell-ink);
903}
904.issue-board-skeleton {
905 animation: issue-board-pulse 1.5s ease-in-out infinite alternate;
906}
907@keyframes issue-board-pulse {
908 to {
909 opacity: 0.5;
910 }
911}
912
913/*
914 * Issue detail, Linear-style: the page is the canvas (no framing cards),
915 * comments are the only cards, and the right rail is a list of plain rows.
916 */
917.issue-detail {
918 display: flex;
919 max-width: 1180px;
920 flex-direction: column;
921 gap: 14px;
922 margin: 6px auto 64px;
923}
924.issue-detail-notice {
925 padding: 6px 10px;
926 border-radius: 8px;
927 background: var(--shell-field);
928 color: var(--shell-ink-muted);
929 font-size: 12.5px;
930}
931
932.issue-detail-topline {
933 display: flex;
934 flex-wrap: wrap;
935 align-items: center;
936 gap: 6px;
937}
938.issue-detail-actions {
939 display: flex;
940 flex-wrap: wrap;
941 align-items: center;
942 gap: 6px;
943 margin-left: auto;
944}
945.issue-action {
946 display: inline-flex;
947 align-items: center;
948 gap: 6px;
949 height: 30px;
950 padding-inline: 9px;
951 border-radius: 7px;
952 color: var(--shell-ink-muted);
953 font-size: 12.5px;
954 transition:
955 background 140ms,
956 color 140ms;
957}
958.issue-action:hover {
959 background: var(--shell-field);
960 color: var(--shell-ink);
961}
962.issue-action-strong {
963 color: var(--shell-ink);
964 font-weight: 600;
965}
966.issue-action-strong:hover {
967 background: var(--shell-active);
968}
969.issue-action-danger {
970 height: 30px;
971 padding-inline: 9px;
972 border-radius: 7px;
973 color: #b42318;
974 font-size: 12.5px;
975}
976.issue-action-danger:hover {
977 background: color-mix(in srgb, #e5484d 10%, transparent);
978 color: #b42318;
979}
980.issue-action-danger[data-armed="true"] {
981 background: #e5484d;
982 color: #fff;
983 font-weight: 600;
984}
985
986.issue-detail-layout {
987 display: flex;
988 align-items: flex-start;
989 gap: 48px;
990}
991.issue-detail-main {
992 display: flex;
993 min-width: 0;
994 max-width: 780px;
995 flex: 1 1 auto;
996 flex-direction: column;
997 gap: 22px;
998}
999.issue-detail-rail {
1000 display: flex;
1001 width: 268px;
1002 flex-shrink: 0;
1003 flex-direction: column;
1004 gap: 20px;
1005 margin-left: auto;
1006}
1007
1008.issue-detail-key {
1009 display: flex;
1010 align-items: center;
1011 gap: 8px;
1012}
1013.issue-detail-number {
1014 color: var(--shell-ink-muted);
1015 font-family: var(--font-mono);
1016 font-size: 12px;
1017}
1018.issue-detail-title {
1019 margin-top: 10px;
1020 font-size: 24px;
1021 font-weight: 650;
1022 line-height: 1.35;
1023 letter-spacing: -0.02em;
1024}
1025.issue-detail-title .gitdata-markdown {
1026 color: inherit;
1027 font-size: inherit;
1028 font-weight: inherit;
1029 line-height: inherit;
1030 letter-spacing: inherit;
1031}
1032.issue-detail-title .gitdata-markdown code {
1033 font-size: 0.9em;
1034}
1035.issue-detail-desc {
1036 margin-top: 10px;
1037 font-size: 13.5px;
1038 line-height: 1.75;
1039 word-break: break-word;
1040}
1041.issue-detail-desc.is-empty {
1042 color: var(--shell-ink-muted);
1043}
1044.issue-detail-byline {
1045 display: flex;
1046 flex-wrap: wrap;
1047 align-items: center;
1048 gap: 8px;
1049 margin-top: 16px;
1050 color: var(--shell-ink-muted);
1051 font-size: 12px;
1052}
1053
1054.issue-feed {
1055 display: flex;
1056 flex-direction: column;
1057 gap: 14px;
1058 padding-top: 18px;
1059 border-top: 1px solid var(--shell-hairline);
1060}
1061.issue-feed-head {
1062 display: flex;
1063 min-height: 30px;
1064 align-items: center;
1065 gap: 8px;
1066}
1067.issue-feed-head h2 {
1068 font-size: 13px;
1069 font-weight: 600;
1070}
1071.issue-feed-count {
1072 color: var(--shell-ink-muted);
1073 font-size: 12px;
1074}
1075.issue-feed-head-actions {
1076 margin-left: auto;
1077}
1078.issue-feed-note {
1079 color: var(--shell-ink-muted);
1080 font-size: 12.5px;
1081}
1082.issue-feed-more {
1083 display: inline-flex;
1084 align-self: flex-start;
1085 align-items: center;
1086 gap: 6px;
1087 height: 28px;
1088 padding-inline: 8px;
1089 border-radius: 7px;
1090 color: var(--shell-ink-muted);
1091 font-size: 12px;
1092 transition:
1093 background 140ms,
1094 color 140ms;
1095}
1096.issue-feed-more:hover {
1097 background: var(--shell-field);
1098 color: var(--shell-ink);
1099}
1100
1101.issue-event {
1102 display: grid;
1103 grid-template-columns: 20px minmax(0, 1fr);
1104 align-items: start;
1105 gap: 10px;
1106}
1107.issue-event-icon {
1108 display: grid;
1109 width: 20px;
1110 height: 20px;
1111 place-items: center;
1112}
1113.issue-event-dot {
1114 display: grid;
1115 width: 20px;
1116 height: 20px;
1117 place-items: center;
1118 border-radius: 50%;
1119 background: var(--shell-field);
1120 color: var(--shell-ink-muted);
1121}
1122.issue-event-stack {
1123 display: flex;
1124 min-width: 0;
1125 flex-direction: column;
1126 gap: 2px;
1127}
1128.issue-event-body {
1129 min-width: 0;
1130 padding-top: 1px;
1131 color: var(--shell-ink-muted);
1132 font-size: 12.5px;
1133 line-height: 1.6;
1134}
1135.issue-event-actor {
1136 color: var(--shell-ink);
1137 font-weight: 550;
1138}
1139.issue-event-extra {
1140 color: var(--shell-ink-muted);
1141}
1142/* The changed value is the point of the line: keep it in ink, not in grey. */
1143.issue-event-value {
1144 padding: 1px 5px;
1145 border-radius: 5px;
1146 background: var(--shell-field);
1147 color: var(--shell-ink);
1148 font-weight: 550;
1149}
1150.issue-event-toggle {
1151 display: inline-flex;
1152 align-self: flex-start;
1153 align-items: center;
1154 gap: 4px;
1155 margin-top: 2px;
1156 padding: 2px 6px 2px 4px;
1157 border-radius: 6px;
1158 color: var(--shell-ink-muted);
1159 font-size: 11.5px;
1160 transition:
1161 background 140ms,
1162 color 140ms;
1163}
1164.issue-event-toggle:hover {
1165 background: var(--shell-field);
1166 color: var(--shell-ink);
1167}
1168.issue-event-time {
1169 color: var(--shell-ink-muted);
1170 font-size: 11.5px;
1171}
1172
1173.comment-card {
1174 padding: 14px 16px;
1175 border: 1px solid var(--shell-hairline);
1176 border-radius: 10px;
1177 background: var(--shell-surface);
1178 box-shadow: 0 1px 2px rgb(20 30 60 / 4%);
1179}
1180.comment-head {
1181 display: flex;
1182 align-items: center;
1183 gap: 8px;
1184}
1185.comment-author {
1186 font-size: 12.5px;
1187 font-weight: 600;
1188}
1189.comment-time {
1190 color: var(--shell-ink-muted);
1191 font-size: 11.5px;
1192}
1193.comment-actions {
1194 display: flex;
1195 align-items: center;
1196 gap: 2px;
1197 margin-left: auto;
1198}
1199.comment-action {
1200 padding: 3px 6px;
1201 border-radius: 6px;
1202 color: var(--shell-ink-muted);
1203 font-size: 11.5px;
1204 transition:
1205 background 140ms,
1206 color 140ms;
1207}
1208.comment-action:hover {
1209 background: var(--shell-field);
1210 color: var(--shell-ink);
1211}
1212.comment-body {
1213 margin-top: 8px;
1214 font-size: 13px;
1215 line-height: 1.75;
1216 white-space: pre-wrap;
1217 word-break: break-word;
1218}
1219.comment-error {
1220 margin-top: 6px;
1221 color: #b42318;
1222 font-size: 12px;
1223}
1224.comment-editor {
1225 display: flex;
1226 flex-direction: column;
1227 gap: 8px;
1228 margin-top: 10px;
1229}
1230.comment-input {
1231 width: 100%;
1232 min-height: 44px;
1233 padding: 8px 10px;
1234 border: 1px solid var(--shell-hairline);
1235 border-radius: 8px;
1236 background: var(--shell-field);
1237 font-size: 13px;
1238 line-height: 1.7;
1239 outline: none;
1240 resize: vertical;
1241 transition:
1242 border-color 150ms,
1243 background 150ms;
1244}
1245.comment-input:focus {
1246 border-color: var(--shell-tile);
1247 background: var(--shell-surface);
1248}
1249.comment-replies {
1250 display: flex;
1251 flex-direction: column;
1252 gap: 10px;
1253 margin-top: 12px;
1254}
1255.comment-replies .comment-card {
1256 border: 0;
1257 background: var(--shell-board);
1258 box-shadow: none;
1259}
1260.comment-reply-row {
1261 display: flex;
1262 align-items: center;
1263 gap: 8px;
1264 margin-top: 12px;
1265 padding-top: 10px;
1266 border-top: 1px solid var(--shell-hairline);
1267}
1268.comment-reply-input {
1269 min-width: 0;
1270 flex: 1;
1271 height: 28px;
1272 padding-inline: 2px;
1273 font-size: 12.5px;
1274 outline: none;
1275}
1276.comment-send {
1277 display: grid;
1278 width: 26px;
1279 height: 26px;
1280 flex-shrink: 0;
1281 place-items: center;
1282 border-radius: 7px;
1283 color: var(--shell-ink-muted);
1284 transition:
1285 background 140ms,
1286 color 140ms,
1287 opacity 140ms;
1288}
1289.comment-send:disabled {
1290 opacity: 0.45;
1291}
1292.comment-send:not(:disabled) {
1293 background: var(--shell-action);
1294 color: #fff;
1295}
1296.comment-send:not(:disabled):hover {
1297 filter: brightness(1.08);
1298}
1299/* One box owns the border: the textarea inside it stays bare. */
1300.issue-composer {
1301 display: flex;
1302 flex-direction: column;
1303 gap: 8px;
1304 padding: 12px 14px 10px;
1305 transition:
1306 border-color 150ms,
1307 box-shadow 150ms;
1308}
1309.issue-composer:focus-within {
1310 border-color: var(--shell-tile);
1311 box-shadow:
1312 0 1px 2px rgb(20 30 60 / 4%),
1313 0 0 0 3px color-mix(in srgb, var(--shell-focus) 16%, transparent);
1314}
1315.issue-composer .comment-input {
1316 min-height: 42px;
1317 padding: 0;
1318 border: 0;
1319 border-radius: 0;
1320 background: transparent;
1321 resize: none;
1322}
1323.issue-composer .comment-input:focus {
1324 border: 0;
1325 background: transparent;
1326}
1327.issue-detail .issue-composer .comment-input:focus-visible {
1328 outline: none;
1329}
1330.comment-foot {
1331 display: flex;
1332 align-items: center;
1333 justify-content: flex-end;
1334 gap: 8px;
1335}
1336
1337/* Right rail: grouped rows instead of boxes. */
1338.issue-rail-group {
1339 display: flex;
1340 flex-direction: column;
1341 gap: 4px;
1342}
1343.issue-rail-title {
1344 padding-inline: 8px;
1345 color: var(--shell-ink-muted);
1346 font-size: 11.5px;
1347 font-weight: 550;
1348 letter-spacing: 0.02em;
1349}
1350.issue-rail-rows {
1351 display: flex;
1352 flex-direction: column;
1353}
1354.issue-rail-labels {
1355 display: flex;
1356 flex-wrap: wrap;
1357 align-items: center;
1358 gap: 6px;
1359 padding: 2px 8px;
1360}
1361.issue-rail-empty {
1362 color: var(--shell-ink-muted);
1363 font-size: 12px;
1364}
1365.issue-rail-error {
1366 color: #b42318;
1367 font-size: 11.5px;
1368}
1369
1370.issue-prop-row {
1371 display: flex;
1372 width: 100%;
1373 height: 30px;
1374 align-items: center;
1375 gap: 8px;
1376 padding-inline: 8px;
1377 border-radius: 7px;
1378 color: var(--shell-ink);
1379 font-size: 12.5px;
1380 transition: background 140ms;
1381}
1382.issue-prop-row:hover {
1383 background: var(--shell-field);
1384}
1385.issue-prop-icon {
1386 flex-shrink: 0;
1387 color: var(--shell-ink-muted);
1388}
1389.issue-prop-label {
1390 flex-shrink: 0;
1391 color: var(--shell-ink-muted);
1392}
1393.issue-prop-value {
1394 display: flex;
1395 min-width: 0;
1396 align-items: center;
1397 gap: 6px;
1398 margin-left: auto;
1399}
1400.issue-prop-text {
1401 overflow: hidden;
1402 text-overflow: ellipsis;
1403 white-space: nowrap;
1404}
1405.issue-prop-text.is-empty {
1406 color: var(--shell-ink-muted);
1407}
1408.issue-prop-chevron {
1409 flex-shrink: 0;
1410 color: var(--shell-ink-muted);
1411 opacity: 0;
1412 transition: opacity 140ms;
1413}
1414.issue-prop-row:hover .issue-prop-chevron,
1415.issue-prop-row[aria-expanded="true"] .issue-prop-chevron {
1416 opacity: 0.7;
1417}
1418.issue-prop-clear {
1419 display: grid;
1420 width: 18px;
1421 height: 18px;
1422 flex-shrink: 0;
1423 place-items: center;
1424 border-radius: 5px;
1425 color: var(--shell-ink-muted);
1426}
1427.issue-prop-clear:hover {
1428 background: var(--shell-active);
1429 color: var(--shell-ink);
1430}
1431/* The date field is a rail row like the others; the native input only holds
1432 the value and feeds the picker. */
1433.issue-prop-row-action {
1434 position: relative;
1435 padding-inline: 8px;
1436}
1437.issue-prop-target {
1438 display: flex;
1439 min-width: 0;
1440 flex: 1;
1441 align-items: center;
1442 gap: 8px;
1443 text-align: left;
1444}
1445.issue-prop-date-sr {
1446 position: absolute;
1447 width: 1px;
1448 height: 1px;
1449 padding: 0;
1450 border: 0;
1451 clip-path: inset(50%);
1452 opacity: 0;
1453 pointer-events: none;
1454}
1455.issue-prop-popover {
1456 position: absolute;
1457 top: calc(100% + 4px);
1458 right: 0;
1459 z-index: 40;
1460 width: 248px;
1461 max-height: 280px;
1462 overflow-y: auto;
1463}
1464.issue-prop-add {
1465 display: inline-flex;
1466 align-items: center;
1467 gap: 4px;
1468 height: 24px;
1469 padding-inline: 6px;
1470 border-radius: 6px;
1471 color: var(--shell-ink-muted);
1472 font-size: 12px;
1473 transition:
1474 background 140ms,
1475 color 140ms;
1476}
1477.issue-prop-add:hover {
1478 background: var(--shell-field);
1479 color: var(--shell-ink);
1480}
1481.issue-check {
1482 display: grid;
1483 width: 14px;
1484 height: 14px;
1485 flex-shrink: 0;
1486 place-items: center;
1487 border: 1px solid var(--shell-tile);
1488 border-radius: 4px;
1489 color: #fff;
1490 transition:
1491 background 120ms,
1492 border-color 120ms;
1493}
1494.issue-check.is-on {
1495 border-color: var(--shell-ink);
1496 background: var(--shell-ink);
1497}
1498
1499.issue-pull-links {
1500 display: flex;
1501 flex-direction: column;
1502}
1503.issue-pull-card {
1504 position: relative;
1505 display: flex;
1506 flex-direction: column;
1507 gap: 3px;
1508 margin-bottom: 6px;
1509 padding: 8px 26px 8px 10px;
1510 border-radius: 9px;
1511 background: var(--shell-field);
1512}
1513.issue-pull-card-head {
1514 display: flex;
1515 min-width: 0;
1516 align-items: center;
1517 gap: 6px;
1518 color: var(--shell-ink);
1519 font-size: 12.5px;
1520}
1521.issue-pull-card-head > svg {
1522 flex: none;
1523}
1524.issue-pull-card-title {
1525 min-width: 0;
1526 flex: 1 1 auto;
1527 overflow: hidden;
1528 text-overflow: ellipsis;
1529 white-space: nowrap;
1530}
1531/*
1532 * 状态徽标不许被长标题挤扁:flex:none + nowrap 保持一行,底色跟随 data-state
1533 * 的颜色(currentColor),与看板状态徽标观感一致。
1534 */
1535.issue-pull-state {
1536 flex: none;
1537 padding: 0 6px;
1538 border-radius: 999px;
1539 background: color-mix(in srgb, currentColor 12%, transparent);
1540 font-size: 11px;
1541 font-weight: 600;
1542 line-height: 18px;
1543 white-space: nowrap;
1544}
1545.issue-pull-card-foot {
1546 display: flex;
1547 min-width: 0;
1548 align-items: center;
1549 gap: 6px;
1550}
1551.issue-pull-branch {
1552 min-width: 0;
1553 flex: 1 1 auto;
1554 overflow: hidden;
1555 color: var(--shell-ink-muted);
1556 font-family: var(--font-mono);
1557 font-size: 11px;
1558 text-overflow: ellipsis;
1559 white-space: nowrap;
1560}
1561.issue-pull-branch-base {
1562 flex: 0 1 auto;
1563}
1564.issue-pull-arrow {
1565 flex: none;
1566 color: var(--shell-ink-muted);
1567 font-size: 11px;
1568}
1569.issue-pull-card-remove {
1570 position: absolute;
1571 top: 8px;
1572 right: 8px;
1573}
1574.issue-pull-state[data-state="open"] {
1575 color: #3ba55d;
1576}
1577.issue-pull-state[data-state="merged"] {
1578 color: #8e4ec6;
1579}
1580.issue-pull-state[data-state="closed"] {
1581 color: #b42318;
1582}
1583.issue-pull-picker {
1584 display: flex;
1585 flex-direction: column;
1586 gap: 2px;
1587 margin-top: 4px;
1588 padding: 4px;
1589 border: 1px solid var(--shell-hairline);
1590 border-radius: 9px;
1591}
1592.issue-pull-picker-foot {
1593 display: flex;
1594 align-items: center;
1595 gap: 6px;
1596 padding: 2px 4px 4px;
1597}
1598.issue-pull-cancel {
1599 padding: 4px 8px;
1600 border-radius: 6px;
1601 color: var(--shell-ink-muted);
1602 font-size: 11.5px;
1603 transition:
1604 background 140ms,
1605 color 140ms;
1606}
1607.issue-pull-cancel:hover {
1608 background: var(--shell-field);
1609 color: var(--shell-ink);
1610}
1611.issue-more {
1612 position: relative;
1613}
1614.issue-more-menu {
1615 position: absolute;
1616 top: calc(100% + 6px);
1617 right: 0;
1618 z-index: 30;
1619 min-width: 168px;
1620 padding: 4px;
1621 border: 1px solid var(--shell-hairline);
1622 border-radius: 10px;
1623 background: var(--shell-surface);
1624 box-shadow: 0 14px 36px rgb(20 30 60 / 16%);
1625}
1626.issue-more-danger {
1627 display: flex;
1628 width: 100%;
1629 min-height: 30px;
1630 align-items: center;
1631 gap: 8px;
1632 padding-inline: 8px;
1633 border-radius: 7px;
1634 font-size: 12.5px;
1635}
1636.issue-pull-state {
1637 color: var(--shell-ink-muted);
1638}
1639.issue-link-button {
1640 align-self: flex-start;
1641 height: 28px;
1642 margin: 4px 0 0 8px;
1643 padding-inline: 10px;
1644 border-radius: 7px;
1645 background: var(--shell-active);
1646 font-size: 12px;
1647 font-weight: 550;
1648 transition: background 140ms;
1649}
1650.issue-link-button:not(:disabled):hover {
1651 background: var(--shell-tile);
1652}
1653.issue-link-button:disabled {
1654 opacity: 0.5;
1655}
1656
1657/*
1658 * Create/edit issue dialog, Linear's composer: breadcrumb header, bare
1659 * title/description fields, and one row of chips above the primary action.
1660 */
1661.issue-form-dialog {
1662 padding: 18px 22px 16px;
1663 border-radius: 18px;
1664}
1665.issue-form-dialog .issue-dialog-header {
1666 margin-bottom: 4px;
1667 align-items: center;
1668}
1669.issue-form-dialog .issue-dialog-content {
1670 overflow: visible;
1671 padding-right: 0;
1672}
1673.issue-form-dialog .issue-dialog-footer {
1674 margin-top: 16px;
1675 padding-top: 0;
1676 border-top: 0;
1677}
1678.issue-form-head {
1679 display: flex;
1680 min-width: 0;
1681 align-items: center;
1682 gap: 6px;
1683}
1684.issue-form-head > svg {
1685 flex-shrink: 0;
1686 color: var(--shell-ink-muted);
1687}
1688.issue-form-team {
1689 display: inline-flex;
1690 height: 22px;
1691 align-items: center;
1692 padding-inline: 8px;
1693 border-radius: 6px;
1694 background: var(--shell-field);
1695 color: var(--shell-ink);
1696 font-size: 11.5px;
1697 font-weight: 600;
1698}
1699.issue-form-kicker {
1700 overflow: hidden;
1701 color: var(--shell-ink-muted);
1702 font-size: 12.5px;
1703 text-overflow: ellipsis;
1704 white-space: nowrap;
1705}
1706
1707.issue-form-body {
1708 display: flex;
1709 flex-direction: column;
1710 gap: 2px;
1711}
1712.issue-form-title-row {
1713 display: flex;
1714 align-items: center;
1715 gap: 10px;
1716}
1717.issue-form-dialog .issue-form-title {
1718 width: 100%;
1719 height: 44px;
1720 padding: 0;
1721 border: 0;
1722 background: transparent;
1723 color: var(--shell-ink);
1724 font-size: 19px;
1725 font-weight: 600;
1726 letter-spacing: -0.015em;
1727 outline: none;
1728}
1729.issue-form-dialog .issue-form-summary {
1730 width: 100%;
1731 height: 32px;
1732 padding: 0;
1733 border: 0;
1734 background: transparent;
1735 color: var(--shell-ink);
1736 font-size: 13.5px;
1737 outline: none;
1738}
1739.issue-form-dialog .issue-form-desc {
1740 width: 100%;
1741 min-height: 76px;
1742 max-height: 300px;
1743 margin-top: 6px;
1744 padding: 0;
1745 border: 0;
1746 border-radius: 0;
1747 background: transparent;
1748 color: var(--shell-ink);
1749 font-size: 13px;
1750 line-height: 1.75;
1751 outline: none;
1752 resize: none;
1753 overflow-y: auto;
1754}
1755.issue-form-dialog .issue-form-desc:focus {
1756 border: 0;
1757 background: transparent;
1758 box-shadow: none;
1759}
1760.issue-form-dialog :is(.issue-form-title, .issue-form-summary, .issue-form-desc):focus-visible {
1761 outline: none;
1762}
1763.issue-form-dialog :is(.issue-form-title, .issue-form-summary, .issue-form-desc)::placeholder {
1764 color: var(--shell-ink-muted);
1765}
1766.issue-form-count {
1767 flex-shrink: 0;
1768 color: var(--shell-ink-muted);
1769 font-size: 11px;
1770 font-variant-numeric: tabular-nums;
1771}
1772.issue-form-preview {
1773 width: 100%;
1774 min-height: 76px;
1775 margin-top: 6px;
1776 padding: 0;
1777}
1778.issue-form-desc-tools {
1779 display: flex;
1780 justify-content: flex-end;
1781}
1782.issue-form-preview-toggle {
1783 height: 24px;
1784 padding-inline: 6px;
1785 border-radius: 6px;
1786 color: var(--shell-ink-muted);
1787 font-size: 12px;
1788 transition:
1789 background 140ms,
1790 color 140ms;
1791}
1792.issue-form-preview-toggle:hover {
1793 background: var(--shell-field);
1794 color: var(--shell-ink);
1795}
1796
1797.issue-form-bar {
1798 display: flex;
1799 width: 100%;
1800 flex-wrap: wrap;
1801 align-items: center;
1802 gap: 8px;
1803}
1804.issue-form-chips {
1805 display: flex;
1806 min-width: 0;
1807 flex: 1 1 auto;
1808 flex-wrap: wrap;
1809 align-items: center;
1810 gap: 6px;
1811}
1812.issue-form-submit {
1813 display: flex;
1814 margin-left: auto;
1815 align-items: center;
1816 gap: 8px;
1817}
1818
1819.issue-form-chip {
1820 position: relative;
1821 display: inline-flex;
1822 height: 28px;
1823 align-items: center;
1824 gap: 6px;
1825 padding-inline: 10px;
1826 border: 1px solid var(--shell-hairline);
1827 border-radius: 999px;
1828 background: transparent;
1829 color: var(--shell-ink-muted);
1830 font-size: 12px;
1831 transition:
1832 background 140ms,
1833 color 140ms;
1834}
1835.issue-form-chip > svg {
1836 flex-shrink: 0;
1837}
1838.issue-form-chip.is-button:hover,
1839.issue-form-chip.is-button:focus-within {
1840 background: var(--shell-field);
1841 color: var(--shell-ink);
1842}
1843.issue-form-chip.is-disabled {
1844 opacity: 0.55;
1845}
1846.issue-form-chip-main {
1847 display: inline-flex;
1848 height: 100%;
1849 align-items: center;
1850 gap: 6px;
1851 padding: 0;
1852 color: inherit;
1853 font-size: inherit;
1854}
1855.issue-chip-text {
1856 max-width: 150px;
1857 overflow: hidden;
1858 color: var(--shell-ink);
1859 text-overflow: ellipsis;
1860 white-space: nowrap;
1861}
1862.issue-chip-text.is-empty {
1863 color: var(--shell-ink-muted);
1864}
1865.issue-form-attach {
1866 display: grid;
1867 width: 28px;
1868 height: 28px;
1869 flex-shrink: 0;
1870 place-items: center;
1871 border: 1px solid var(--shell-hairline);
1872 border-radius: 999px;
1873 color: var(--shell-ink-muted);
1874 transition:
1875 background 140ms,
1876 color 140ms;
1877}
1878.issue-form-attach:hover {
1879 background: var(--shell-field);
1880 color: var(--shell-ink);
1881}
1882.issue-form-chip.is-draft {
1883 max-width: 220px;
1884}
1885.issue-form-chip.is-draft.is-failed {
1886 border-color: color-mix(in srgb, #e5484d 45%, var(--shell-hairline));
1887 color: #b42318;
1888}
1889.issue-chip-size {
1890 flex-shrink: 0;
1891 color: var(--shell-ink-muted);
1892 font-size: 11px;
1893 font-variant-numeric: tabular-nums;
1894}
1895.issue-chip-dot {
1896 width: 8px;
1897 height: 8px;
1898 flex-shrink: 0;
1899 border-radius: 50%;
1900}
1901.issue-chip-clear {
1902 display: grid;
1903 width: 16px;
1904 height: 16px;
1905 flex-shrink: 0;
1906 place-items: center;
1907 border-radius: 50%;
1908 color: var(--shell-ink-muted);
1909}
1910.issue-chip-clear:hover {
1911 background: var(--shell-active);
1912 color: var(--shell-ink);
1913}
1914.issue-chip-date {
1915 position: absolute;
1916 right: 10px;
1917 bottom: 0;
1918 width: 1px;
1919 height: 1px;
1920 opacity: 0;
1921 pointer-events: none;
1922}
1923
1924/* Attachments under the issue description. */
1925.issue-attachments {
1926 display: flex;
1927 flex-wrap: wrap;
1928 align-items: center;
1929 gap: 8px;
1930 margin-top: 14px;
1931}
1932.issue-attachment {
1933 position: relative;
1934 display: inline-flex;
1935 max-width: 100%;
1936 align-items: center;
1937}
1938.issue-attachment.is-file {
1939 height: 30px;
1940 padding-inline: 10px;
1941 border: 1px solid var(--shell-hairline);
1942 border-radius: 8px;
1943 background: var(--shell-surface);
1944 transition:
1945 border-color 140ms,
1946 background 140ms;
1947}
1948.issue-attachment.is-file:hover {
1949 border-color: var(--shell-tile);
1950 background: var(--shell-field);
1951}
1952.issue-attachment-link {
1953 display: inline-flex;
1954 min-width: 0;
1955 align-items: center;
1956 gap: 7px;
1957 padding: 0;
1958 border: 0;
1959 background: transparent;
1960 color: var(--shell-ink);
1961 font-size: 12.5px;
1962}
1963.issue-attachment-link > svg {
1964 flex-shrink: 0;
1965 color: var(--shell-ink-muted);
1966}
1967.issue-attachment-name {
1968 max-width: 240px;
1969 overflow: hidden;
1970 text-overflow: ellipsis;
1971 white-space: nowrap;
1972}
1973.issue-attachment-size {
1974 flex-shrink: 0;
1975 color: var(--shell-ink-muted);
1976 font-size: 11.5px;
1977 font-variant-numeric: tabular-nums;
1978}
1979.issue-attachment-image {
1980 display: block;
1981 overflow: hidden;
1982 padding: 0;
1983 border: 1px solid var(--shell-hairline);
1984 border-radius: 8px;
1985 background: var(--shell-field);
1986 line-height: 0;
1987 transition: border-color 140ms;
1988}
1989.issue-attachment-image:hover {
1990 border-color: var(--shell-tile);
1991}
1992.issue-attachment-image img {
1993 display: block;
1994 width: 132px;
1995 height: 88px;
1996 object-fit: cover;
1997}
1998.issue-attachment-remove {
1999 position: absolute;
2000 top: -6px;
2001 right: -6px;
2002 display: grid;
2003 width: 18px;
2004 height: 18px;
2005 place-items: center;
2006 border: 1px solid var(--shell-hairline);
2007 border-radius: 50%;
2008 background: var(--shell-surface);
2009 color: var(--shell-ink-muted);
2010 opacity: 0;
2011 transition:
2012 opacity 140ms,
2013 color 140ms;
2014}
2015.issue-attachment:hover .issue-attachment-remove,
2016.issue-attachment:focus-within .issue-attachment-remove {
2017 opacity: 1;
2018}
2019.issue-attachment-remove:hover {
2020 color: #b42318;
2021}
2022
2023/* The dialog's FilterMenus wear the same chip and open upwards. */
2024.issue-form-bar .board-filter-trigger {
2025 height: 28px;
2026 padding-inline: 10px;
2027 gap: 6px;
2028 border: 1px solid var(--shell-hairline);
2029 border-radius: 999px;
2030 background: transparent;
2031 color: var(--shell-ink-muted);
2032 font-size: 12px;
2033}
2034.issue-form-bar .board-filter-trigger:hover,
2035.issue-form-bar .board-filter-trigger[aria-expanded="true"] {
2036 border-color: var(--shell-hairline);
2037 background: var(--shell-field);
2038 color: var(--shell-ink);
2039}
2040.issue-form-bar .board-filter-trigger-value {
2041 max-width: 150px;
2042}
2043.issue-form-bar .board-filter-chevron {
2044 opacity: 0;
2045 transition: opacity 140ms;
2046}
2047.issue-form-bar .board-filter-trigger:hover .board-filter-chevron,
2048.issue-form-bar .board-filter-trigger[aria-expanded="true"] .board-filter-chevron {
2049 opacity: 0.7;
2050}
2051.issue-form-bar .board-filter-popover {
2052 top: auto;
2053 bottom: calc(100% + 6px);
2054 margin: 0;
2055}
2056.issue-form-popover {
2057 position: absolute;
2058 bottom: calc(100% + 6px);
2059 left: 0;
2060 z-index: 40;
2061 width: 248px;
2062 max-height: 280px;
2063 overflow-y: auto;
2064}
2065.issue-form-popover.is-right {
2066 left: auto;
2067 right: 0;
2068}
2069
2070/* Bare filter trigger = a rail row that opens the picker. */
2071.board-filter-trigger-bare {
2072 width: 100%;
2073 height: 30px;
2074 justify-content: flex-start;
2075 gap: 8px;
2076 padding-inline: 8px;
2077 border: 0;
2078 border-radius: 7px;
2079 background: transparent;
2080 color: var(--shell-ink);
2081}
2082.board-filter-trigger-bare:hover,
2083.board-filter-trigger-bare[aria-expanded="true"] {
2084 background: var(--shell-field);
2085}
2086.board-filter-trigger-bare .board-filter-leading,
2087.board-filter-trigger-bare .board-filter-leading > span {
2088 color: var(--shell-ink-muted);
2089}
2090.board-filter-trigger-bare .board-filter-trigger-value {
2091 max-width: 150px;
2092 margin-left: auto;
2093}
2094.board-filter-trigger-bare .board-filter-chevron {
2095 opacity: 0;
2096 transition: opacity 140ms;
2097}
2098.board-filter-trigger-bare:hover .board-filter-chevron,
2099.board-filter-trigger-bare[aria-expanded="true"] .board-filter-chevron {
2100 opacity: 0.7;
2101}
2102.issue-dialog-overlay {
2103 z-index: 60;
2104 background: rgb(22 28 46 / 42%);
2105 backdrop-filter: blur(5px);
2106}
2107.issue-dialog {
2108 max-height: min(90dvh, 880px);
2109 padding: 26px;
2110 border-width: 1px;
2111 border-radius: 20px;
2112 box-shadow: 0 24px 80px rgb(10 20 40 / 22%);
2113 outline: none;
2114}
2115.issue-dialog-header {
2116 margin-bottom: 22px;
2117}
2118.issue-dialog-header h2 {
2119 font-size: 21px;
2120 font-weight: 650;
2121 line-height: 1.4;
2122 letter-spacing: -0.02em;
2123}
2124.issue-dialog-header p {
2125 margin-top: 7px;
2126 line-height: 1.75;
2127 font-size: 12px;
2128}
2129.issue-dialog-close {
2130 display: grid;
2131 place-items: center;
2132 width: 30px;
2133 height: 30px;
2134 margin-top: -3px;
2135 margin-right: -6px;
2136 border-radius: 8px;
2137}
2138.issue-dialog-close:hover {
2139 background: var(--shell-field);
2140}
2141.issue-dialog-content {
2142 padding-right: 3px;
2143}
2144.issue-dialog-content > * + * {
2145 margin-top: 20px;
2146}
2147.issue-dialog-footer {
2148 gap: 9px;
2149 padding-top: 18px;
2150 margin-top: 22px;
2151 border-top: 1px solid var(--shell-hairline);
2152}
2153.issue-field-label {
2154 display: inline-block;
2155 font-size: 12px;
2156 line-height: 1.6;
2157 font-weight: 550;
2158 letter-spacing: 0;
2159}
2160.issue-field-input,
2161.issue-dialog textarea {
2162 border-width: 1px;
2163 border-radius: 9px;
2164 background: var(--shell-field);
2165 font-size: 13px;
2166 transition:
2167 border-color 150ms,
2168 box-shadow 150ms;
2169}
2170.issue-field-input {
2171 height: 42px;
2172 padding-inline: 12px;
2173}
2174.issue-field-small {
2175 height: 34px;
2176}
2177.issue-field-input:focus,
2178.issue-dialog textarea:focus {
2179 border-color: var(--shell-ink-muted);
2180 box-shadow: 0 0 0 3px rgb(35 39 43 / 5%);
2181}
2182.issue-field-input[aria-invalid="true"] {
2183 border-color: #cb5363;
2184}
2185.issue-picker-option {
2186 border: 1px solid var(--shell-hairline);
2187 padding: 6px 10px;
2188}
2189.issue-picker-option[aria-pressed="false"] {
2190 background: var(--shell-field) !important;
2191 color: var(--shell-ink-muted) !important;
2192}
2193.issue-picker-option[aria-pressed="true"] {
2194 box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--shell-ink) 15%, transparent);
2195}
2196.issue-dialog [role="tablist"] {
2197 gap: 6px;
2198 border-bottom-width: 1px;
2199 padding-bottom: 2px;
2200}
2201.issue-dialog [role="tab"] {
2202 padding: 9px 14px;
2203}
2204.issue-dialog [role="tab"][aria-selected="true"] {
2205 background: var(--shell-field);
2206 border-radius: 7px 7px 0 0;
2207}
2208:is(.board-filters, .issue-board-body, .issue-board-empty, .issue-dialog, .issue-detail)
2209 :is(button, a, input, select, textarea):focus-visible {
2210 outline: 2px solid var(--shell-ink-muted);
2211 outline-offset: 3px;
2212}
2213:is(.board-filters, .issue-dialog, .issue-detail) :is(input, textarea):focus-visible {
2214 outline: none;
2215}
2216.issue-detail :is(button, a, input, textarea):focus-visible {
2217 outline-offset: 1px;
2218}
2219.issue-detail .comment-input:focus-visible,
2220.issue-detail .comment-reply-input:focus-visible {
2221 outline: 2px solid var(--shell-ink-muted);
2222 outline-offset: 1px;
2223}
2224
2225@media (max-width: 1024px) {
2226 .board-filters {
2227 flex-basis: 100%;
2228 }
2229 .board-toolbar-actions {
2230 margin-left: 0;
2231 }
2232 .issue-detail-layout {
2233 gap: 32px;
2234 }
2235 .issue-detail-rail {
2236 width: 240px;
2237 }
2238}
2239@media (max-width: 700px) {
2240 .board-toolbar:has(.board-filters) {
2241 gap: 12px;
2242 padding-block: 14px;
2243 }
2244 .board-search {
2245 max-width: none;
2246 flex-basis: 100%;
2247 }
2248 .board-filters {
2249 gap: 7px;
2250 }
2251 .board-filter-trigger:not(.board-filter-trigger-bare) {
2252 height: 34px;
2253 }
2254 .board-filter-trigger:not(.board-filter-trigger-bare) > span {
2255 max-width: 100px;
2256 }
2257 .board-filter-popover {
2258 width: 205px;
2259 }
2260 .issue-board-column {
2261 width: min(284px, 78vw);
2262 }
2263 .issue-board-columns {
2264 gap: 12px;
2265 }
2266 .issue-board-empty {
2267 min-height: 320px;
2268 padding-inline: 18px;
2269 }
2270 .issue-board-empty > p:first-of-type {
2271 font-size: 17px;
2272 }
2273 .issue-detail-layout {
2274 flex-direction: column;
2275 gap: 26px;
2276 }
2277 .issue-detail-main {
2278 width: 100%;
2279 max-width: none;
2280 }
2281 .issue-detail-rail {
2282 width: 100%;
2283 }
2284 .issue-detail-actions {
2285 flex-basis: 100%;
2286 margin-left: 0;
2287 }
2288 .issue-detail-title {
2289 font-size: 21px;
2290 }
2291 .issue-dialog {
2292 max-height: 92dvh;
2293 padding: 20px;
2294 border-radius: 16px;
2295 }
2296 .issue-dialog-overlay {
2297 padding: 12px;
2298 }
2299 .issue-form-metadata {
2300 grid-template-columns: 1fr;
2301 }
2302 .issue-dialog-content .flex:has(> input[type="date"]) {
2303 flex-wrap: wrap;
2304 }
2305 .issue-dialog-content .flex:has(> input[name="new-label-name"]) {
2306 flex-wrap: wrap;
2307 }
2308 .issue-dialog-content input[name="milestone-name"] {
2309 min-width: 150px;
2310 }
2311}
2312@media (prefers-reduced-motion: reduce) {
2313 .issue-board-skeleton {
2314 animation: none;
2315 }
2316 .issue-list-row,
2317 .issue-board-card {
2318 animation: none;
2319 }
2320 .board-filter-summary {
2321 transition: none;
2322 }
2323 .issue-board-card:hover,
2324 .board-create-button:hover {
2325 transform: none;
2326 }
2327}
2328
2329/* ------------------------------------------------- 问题集管理(Linear 风格) --- */
2330.issue-dialog.issue-manage-dialog {
2331 padding: 0;
2332 border: 0;
2333 border-radius: 14px;
2334 overflow: hidden;
2335 box-shadow:
2336 0 24px 60px rgb(15 20 35 / 24%),
2337 0 2px 8px rgb(15 20 35 / 12%);
2338}
2339.issue-manage-dialog .issue-dialog-header {
2340 margin: 0;
2341 align-items: center;
2342 padding: 13px 18px;
2343 border-bottom: 1px solid var(--shell-hairline);
2344}
2345.issue-manage-dialog .issue-dialog-content {
2346 padding: 14px 18px 16px;
2347 overflow: visible;
2348}
2349.issue-manage-dialog .issue-dialog-footer {
2350 margin: 0;
2351 padding: 11px 18px;
2352 border-top: 1px solid var(--shell-hairline);
2353 background: var(--shell-board);
2354}
2355.issue-manage-tabs {
2356 display: inline-flex;
2357 gap: 2px;
2358 padding: 2px;
2359 border-radius: 8px;
2360 background: var(--shell-field);
2361}
2362.issue-manage-dialog .issue-manage-tab {
2363 height: 26px;
2364 padding: 0 12px;
2365 border: 0;
2366 border-radius: 6px;
2367 background: transparent;
2368 color: var(--shell-ink-muted);
2369 font-size: 12.5px;
2370 font-weight: 500;
2371 transition:
2372 background 140ms,
2373 color 140ms,
2374 box-shadow 140ms;
2375}
2376.issue-manage-dialog .issue-manage-tab:hover {
2377 color: var(--shell-ink);
2378}
2379.issue-manage-dialog .issue-manage-tab[data-active] {
2380 background: var(--shell-surface);
2381 color: var(--shell-ink);
2382 box-shadow: 0 1px 2px rgb(20 30 60 / 14%);
2383}
2384.issue-manage-body {
2385 display: flex;
2386 flex-direction: column;
2387 gap: 14px;
2388}
2389.issue-manage-field {
2390 display: flex;
2391 flex-direction: column;
2392 gap: 6px;
2393}
2394.issue-manage-label {
2395 color: var(--shell-ink);
2396 font-size: 12px;
2397 font-weight: 550;
2398}
2399.issue-manage-input {
2400 width: 100%;
2401 height: 34px;
2402 padding: 0 10px;
2403 border: 1px solid var(--shell-hairline);
2404 border-radius: 8px;
2405 background: var(--shell-surface);
2406 color: var(--shell-ink);
2407 font-size: 13px;
2408 outline: none;
2409 transition:
2410 border-color 140ms,
2411 box-shadow 140ms;
2412}
2413.issue-manage-input:disabled {
2414 opacity: 0.6;
2415}
2416/*
2417 * Linear-quiet fields: one hairline, a slightly deeper hairline on hover, and
2418 * a soft grey ring on focus. The shared workspace rules (and the older
2419 * `.issue-field-input:focus`) draw a near-black frame there, which reads as an
2420 * error state instead of "selected" — so every field inside this dialog is
2421 * restated here, at a specificity that wins over those.
2422 */
2423.issue-manage-dialog :is(input, textarea, select):not([type="checkbox"]):not([type="radio"]) {
2424 border: 1px solid var(--shell-hairline);
2425 border-radius: 8px;
2426 background: var(--shell-surface);
2427 box-shadow: none;
2428 transition:
2429 border-color 140ms ease,
2430 box-shadow 140ms ease,
2431 background 140ms ease;
2432}
2433.issue-manage-dialog
2434 :is(input, textarea, select):not([type="checkbox"]):not([type="radio"]):hover:not(:focus):not(:disabled) {
2435 border-color: color-mix(in srgb, var(--shell-ink) 18%, transparent);
2436}
2437.issue-manage-dialog :is(input, textarea, select):not([type="checkbox"]):not([type="radio"]):focus {
2438 border-color: color-mix(in srgb, var(--shell-focus) 55%, transparent);
2439 outline: none;
2440 box-shadow: 0 0 0 3px color-mix(in srgb, var(--shell-focus) 16%, transparent);
2441}
2442.issue-manage-dialog .issue-manage-textarea {
2443 height: auto;
2444 min-height: 74px;
2445 padding: 8px 10px;
2446 line-height: 1.65;
2447 resize: vertical;
2448}
2449.issue-manage-prefix {
2450 width: 168px;
2451 font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
2452 font-size: 12.5px;
2453 letter-spacing: 0.06em;
2454}
2455.issue-manage-prefix.is-invalid {
2456 border-color: #e5484d;
2457}
2458.issue-manage-hint {
2459 color: var(--shell-ink-muted);
2460 font-size: 11.5px;
2461 line-height: 1.55;
2462}
2463.issue-manage-error {
2464 color: #b42318;
2465 font-size: 11.5px;
2466 line-height: 1.55;
2467}
2468.issue-manage-stats {
2469 color: var(--shell-ink-muted);
2470 font-size: 11.5px;
2471}
2472.issue-manage-footer {
2473 display: flex;
2474 width: 100%;
2475 align-items: center;
2476 gap: 8px;
2477}
2478.issue-manage-footer-actions {
2479 display: flex;
2480 margin-left: auto;
2481 align-items: center;
2482 gap: 8px;
2483}
Added view/app/src/domains/issues/ui/dialog.tsx view/app/src/domains/issues/ui/dialog.tsx +148 −0 View view/app/src/domains/issues/ui/dialog.tsx
@@ -0,0 +1,148 @@
1import { useT } from '@app/i18n'
2import { X } from 'lucide-react'
3import { type ReactNode, useEffect, useId, useRef } from 'react'
4
5/**
6 * Feature-local dialog chrome (overlay + panel + Escape/outside-click close),
7 * matching `shared/ui/form-modal`'s look. It exists because the issue forms need
8 * multi-column pickers that the config-driven `FormModal` cannot lay out.
9 */
10export function Dialog({
11 title,
12 description,
13 open,
14 onClose,
15 width = 'max-w-[560px]',
16 panelClassName = '',
17 header,
18 children,
19 footer,
20}: {
21 title: string
22 description?: string
23 open: boolean
24 onClose: () => void
25 width?: string
26 /** Extra classes for the panel (feature-specific chrome). */
27 panelClassName?: string
28 /** Replaces the default title block; the close button and the a11y name stay. */
29 header?: ReactNode
30 children: ReactNode
31 footer?: ReactNode
32}) {
33 const { t } = useT()
34 const panel = useRef<HTMLDivElement>(null)
35 const closeRef = useRef(onClose)
36 const titleId = useId()
37 const descriptionId = useId()
38
39 useEffect(() => {
40 closeRef.current = onClose
41 }, [onClose])
42
43 useEffect(() => {
44 if (!open) return
45 const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
46 const previousOverflow = document.body.style.overflow
47 document.body.style.overflow = 'hidden'
48 const frame = requestAnimationFrame(() => {
49 const firstField = panel.current?.querySelector<HTMLElement>(
50 'input:not([disabled]), textarea:not([disabled]), select:not([disabled])',
51 )
52 ;(firstField ?? panel.current)?.focus()
53 })
54 const onKey = (event: KeyboardEvent) => {
55 if (event.defaultPrevented) return
56 if (event.key === 'Escape') {
57 event.preventDefault()
58 closeRef.current()
59 }
60 if (event.key !== 'Tab') return
61 const controls = Array.from(
62 panel.current?.querySelectorAll<HTMLElement>(
63 'button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), a[href], [tabindex="0"]',
64 ) ?? [],
65 ).filter((element) => element.getClientRects().length > 0)
66 const first = controls[0]
67 const last = controls.at(-1)
68 if (!first) {
69 event.preventDefault()
70 panel.current?.focus()
71 return
72 }
73 if (event.shiftKey && (document.activeElement === first || document.activeElement === panel.current)) {
74 event.preventDefault()
75 last?.focus()
76 } else if (!event.shiftKey && document.activeElement === last) {
77 event.preventDefault()
78 first.focus()
79 }
80 }
81 window.addEventListener('keydown', onKey)
82 return () => {
83 cancelAnimationFrame(frame)
84 document.body.style.overflow = previousOverflow
85 window.removeEventListener('keydown', onKey)
86 if (previousFocus?.isConnected) previousFocus.focus()
87 }
88 }, [open])
89
90 if (!open) return null
91 return (
92 <div
93 className="issue-dialog-overlay fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
94 onClick={onClose}
95 >
96 <div
97 ref={panel}
98 tabIndex={-1}
99 role="dialog"
100 aria-modal
101 aria-labelledby={titleId}
102 aria-describedby={description ? descriptionId : undefined}
103 onClick={(event) => event.stopPropagation()}
104 className={`issue-dialog flex max-h-[86vh] w-full flex-col rounded-xl border-2 border-shell-hairline bg-shell-surface p-5 shadow-xl ${width} ${panelClassName}`}
105 >
106 <div className="issue-dialog-header mb-3 flex items-start justify-between gap-3">
107 {header ? (
108 <>
109 <h2 id={titleId} className="sr-only">
110 {title}
111 </h2>
112 {header}
113 </>
114 ) : (
115 <div className="min-w-0">
116 <h2 id={titleId} className="text-[16px] font-semibold">
117 {title}
118 </h2>
119 {description ? (
120 <p id={descriptionId} className="mt-1 text-[12.5px] leading-[1.5] text-shell-ink-muted">
121 {description}
122 </p>
123 ) : null}
124 </div>
125 )}
126 <button
127 type="button"
128 aria-label={t('issue.board.closeDialog')}
129 onClick={onClose}
130 className="issue-dialog-close shrink-0 text-shell-ink-muted transition-colors hover:text-shell-ink"
131 >
132 <X className="h-4 w-4" />
133 </button>
134 </div>
135 <div className="issue-dialog-content min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">{children}</div>
136 {footer ? (
137 <div className="issue-dialog-footer mt-4 flex shrink-0 items-center justify-end gap-2">{footer}</div>
138 ) : null}
139 </div>
140 </div>
141 )
142}
143
144export const fieldLabelClass = 'issue-field-label text-[12px] font-medium text-shell-ink'
145export const fieldInputClass =
146 'issue-field-input h-10 w-full rounded-md border-2 border-shell-hairline bg-shell-field px-3 text-[13.5px] text-shell-ink outline-none transition-colors focus:border-shell-ink'
147export const smallInputClass =
148 'issue-field-input issue-field-small 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'
Added view/app/src/domains/issues/ui/issue-board.tsx view/app/src/domains/issues/ui/issue-board.tsx +509 −0 View view/app/src/domains/issues/ui/issue-board.tsx
@@ -0,0 +1,509 @@
1/**
2 * The board body: a kanban grouped by priority, or the same rows as a table.
3 *
4 * The API has no column concept — an issue is open/closed and carries a
5 * priority — so the kanban columns *are* the priorities, which makes dragging a
6 * card a real `PATCH …/issues/{id} {priority}` write instead of a client-side
7 * illusion. Loading / empty / error / out-of-range paging are all rendered from
8 * the request state, never from placeholder data.
9 */
10
11import { ArrowRight, Circle, CircleCheck, Inbox, Loader2, Milestone as MilestoneIcon, Plus } from 'lucide-react'
12import { useState } from 'react'
13import './board-polish.css'
14import { ErrorPanel, Markdown } from '@app/components'
15import {
16 type Board,
17 type BoardRef,
18 type IssueBoardView,
19 type IssueListItem,
20 type IssuePriority,
21 issueLabel,
22 issueRef,
23 issueStamp,
24 type Milestone,
25 normalizeColor,
26 PRIORITIES,
27 priorityOf,
28 STATES,
29 updateIssue,
30} from '@app/domains/issues/model'
31import { useT } from '@app/i18n'
32import type { BoardData } from '../model/use-board-data'
33import { AvatarStack, DueChip, FilterMenu, PriorityBadge, PriorityGlyph } from './parts'
34
35export interface IssueBoardBodyProps {
36 boardRef: BoardRef
37 data: BoardData
38 view: IssueBoardView
39 filtered: boolean
40 onFilter: (updates: Partial<IssueBoardView>) => void
41 onClear: () => void
42 /** `issue` is the board-scoped number so the URL stays readable. */
43 onOpenIssue: (issue: string) => void
44 onCreate: (preset?: { priority?: IssuePriority }) => void
45 onChanged: () => void
46 onRetry: () => void
47}
48
49const PAGE_SIZE_FALLBACK = 50
50
51export function IssueBoardBody(props: IssueBoardBodyProps) {
52 const { t, tc } = useT()
53 const { data, view } = props
54 const state = data.issues
55 const failure = state.error as { code?: number; message?: string } | undefined
56 const page = state.data
57 const total = page?.total ?? 0
58 const limit = page?.limit || PAGE_SIZE_FALLBACK
59 const offset = page?.offset ?? (view.page - 1) * limit
60 const loaded = page?.items ?? []
61
62 if (failure && loaded.length === 0) {
63 return (
64 <div className="w-full rounded-lg border-2 border-shell-hairline bg-shell-surface p-4">
65 <ErrorPanel
66 status={typeof failure.code === 'number' ? failure.code : 0}
67 what={t('issue.board.errorList')}
68 note={failure.message}
69 onRetry={props.onRetry}
70 />
71 </div>
72 )
73 }
74
75 if (!page && state.loading) return <BoardSkeleton columns={view.view === 'board' ? 5 : 1} />
76
77 // Paging past the end (a stale `?page=`, or issues deleted meanwhile) would
78 // otherwise read as "no results" — say what it is and offer the way back.
79 if (page && loaded.length === 0 && offset > 0) {
80 return (
81 <div className="flex w-full flex-col items-center gap-3 rounded-lg border-2 border-shell-hairline bg-shell-surface px-4 py-10 text-center">
82 <p className="text-[13px]">{tc('issue.board.pageOutOfRange', total, { page: view.page, total, limit })}</p>
83 <button
84 type="button"
85 onClick={() => props.onFilter({ page: 1 })}
86 className="rounded-md border-2 border-shell-hairline bg-shell-field px-4 py-1.5 text-[13px] transition-colors hover:border-shell-ink"
87 >
88 {t('issue.board.backToFirst')}
89 </button>
90 </div>
91 )
92 }
93
94 const rows = loaded
95
96 if (rows.length === 0) {
97 return props.filtered ? (
98 <EmptyNote
99 title={t('issue.board.emptyFilteredTitle')}
100 hint={t('issue.board.emptyFilteredHint')}
101 action={{ label: t('issue.filter.clear'), onClick: props.onClear }}
102 />
103 ) : (
104 <EmptyNote
105 title={t('issue.board.emptyTitle')}
106 hint={t('issue.board.emptyHint')}
107 action={{ label: t('issue.board.new'), onClick: () => props.onCreate() }}
108 />
109 )
110 }
111
112 return (
113 <div className="issue-board-body flex min-h-0 w-full flex-col gap-2">
114 {view.view === 'board' ? <BoardColumns {...props} rows={rows} /> : <IssueList {...props} rows={rows} />}
115 <Pager view={view} total={total} limit={limit} onFilter={props.onFilter} />
116 {state.loading ? (
117 <p className="flex items-center gap-1.5 text-[11.5px] text-shell-ink-muted">
118 <Loader2 className="h-3 w-3 animate-spin" aria-hidden /> {t('issue.board.refreshing')}
119 </p>
120 ) : null}
121 </div>
122 )
123}
124
125/* ------------------------------------------------------------- kanban --- */
126
127function BoardColumns({
128 boardRef,
129 rows,
130 data,
131 onOpenIssue,
132 onCreate,
133 onChanged,
134}: IssueBoardBodyProps & { rows: IssueListItem[] }) {
135 // Optimistic priority while a PATCH is in flight, so a dropped card never
136 // jumps back. The overrides are stamped with the row set they were painted
137 // over: as soon as the data changes (reload, page switch) they expire on
138 // their own, which keeps a failed write from sticking forever.
139 const { t, tc } = useT()
140 const stamp = rows.map((row) => row.id).join(',')
141 const [override, setOverride] = useState<{
142 stamp: string
143 moves: Record<string, IssuePriority>
144 error: string | null
145 }>({ stamp, moves: {}, error: null })
146 const fresh = override.stamp === stamp
147 const moves = fresh ? override.moves : {}
148 const error = fresh ? override.error : null
149 const [dragging, setDragging] = useState<string | null>(null)
150 const [over, setOver] = useState<IssuePriority | null>(null)
151
152 async function moveTo(issue: IssueListItem, priority: IssuePriority) {
153 if (priorityOf(issue.priority).id === priority) return
154 const base = fresh ? override.moves : {}
155 setOverride({ stamp, moves: { ...base, [issue.id]: priority }, error: null })
156 try {
157 await updateIssue(boardRef, issue.id, { priority })
158 onChanged()
159 } catch (err) {
160 const next = { ...base }
161 delete next[issue.id]
162 setOverride({
163 stamp,
164 moves: next,
165 error: t('issue.board.moveFailed', {
166 priority: priorityOf(priority).label,
167 reason: (err as Error)?.message ?? t('common.state.unknown'),
168 }),
169 })
170 }
171 }
172
173 return (
174 <div className="issue-board-columns flex min-h-0 flex-1 gap-3">
175 {PRIORITIES.map((meta) => {
176 const cards = rows.filter((issue) => (moves[issue.id] ?? issue.priority) === meta.id)
177 return (
178 <section
179 key={meta.id}
180 aria-label={tc('issue.board.columnAria', cards.length, { priority: meta.label, count: cards.length })}
181 data-priority={meta.id}
182 data-drag-over={over === meta.id || undefined}
183 onDragOver={(event) => {
184 event.preventDefault()
185 setOver(meta.id)
186 }}
187 onDragLeave={() => setOver((current) => (current === meta.id ? null : current))}
188 onDrop={(event) => {
189 event.preventDefault()
190 setOver(null)
191 const id = event.dataTransfer.getData('text/plain') || dragging
192 const issue = rows.find((row) => row.id === id)
193 if (issue) void moveTo(issue, meta.id)
194 setDragging(null)
195 }}
196 className={`issue-board-column flex h-full w-[266px] shrink-0 flex-col gap-2 rounded-lg p-2.5 transition-colors ${
197 over === meta.id ? 'bg-shell-active' : 'bg-shell-board'
198 }`}
199 >
200 <header className="issue-board-column-header flex h-6 shrink-0 items-center gap-2">
201 <span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: meta.color }} aria-hidden />
202 <span className="text-[14px] font-semibold">{meta.label}</span>
203 <span className="issue-board-count text-[12px] text-shell-ink-muted">{cards.length}</span>
204 <button
205 type="button"
206 aria-label={t('issue.board.addInColumn', { column: meta.label })}
207 title={t('issue.board.addInColumn', { column: meta.label })}
208 onClick={() => onCreate({ priority: meta.id })}
209 className="issue-board-add ml-auto rounded px-1 text-[15px] leading-none text-shell-ink-muted transition-colors hover:bg-shell-active hover:text-shell-ink"
210 >
211 <Plus className="h-4 w-4" aria-hidden />
212 </button>
213 </header>
214 <div className="min-h-0 flex-1 space-y-2 overflow-y-auto pb-1">
215 {cards.map((issue) => (
216 <IssueCard
217 key={issue.id}
218 issue={{ ...issue, priority: moves[issue.id] ?? issue.priority }}
219 board={data.board.data}
220 moving={dragging === issue.id}
221 milestone={data.milestones.data?.items.find((m) => m.id === issue.milestone_id)}
222 draggable
223 onDragStart={() => setDragging(issue.id)}
224 onDragEnd={() => setDragging(null)}
225 onOpen={() => onOpenIssue(issueRef(data.board.data, issue.number))}
226 />
227 ))}
228 {cards.length === 0 ? (
229 <button
230 type="button"
231 onClick={() => onCreate({ priority: meta.id })}
232 className="issue-board-column-empty"
233 >
234 <Plus className="h-5 w-5" aria-hidden />
235 <span>{t('issue.board.addCard')}</span>
236 <small>{t('issue.board.addCardHint')}</small>
237 </button>
238 ) : null}
239 </div>
240 </section>
241 )
242 })}
243 {error ? (
244 <p
245 role="alert"
246 className="fixed bottom-6 left-1/2 z-40 -translate-x-1/2 rounded-md bg-[#e5484d] px-3 py-2 text-[12.5px] font-medium text-white shadow-lg"
247 >
248 {error}
249 </p>
250 ) : null}
251 </div>
252 )
253}
254
255function IssueCard({
256 issue,
257 board,
258 milestone,
259 onOpen,
260 draggable,
261 moving,
262 onDragStart,
263 onDragEnd,
264}: {
265 issue: IssueListItem
266 board?: Board
267 milestone?: Milestone
268 onOpen: () => void
269 draggable?: boolean
270 moving?: boolean
271 onDragStart?: () => void
272 onDragEnd?: () => void
273}) {
274 const { t } = useT()
275 return (
276 <article
277 draggable={draggable}
278 data-moving={moving || undefined}
279 onDragStart={(event) => {
280 event.dataTransfer.setData('text/plain', issue.id)
281 event.dataTransfer.effectAllowed = 'move'
282 onDragStart?.()
283 }}
284 onDragEnd={onDragEnd}
285 onClick={onOpen}
286 onKeyDown={(event) => {
287 if (event.key === 'Enter' || event.key === ' ') {
288 event.preventDefault()
289 onOpen()
290 }
291 }}
292 role="button"
293 tabIndex={0}
294 aria-label={t('issue.board.cardAria', { number: issueLabel(board, issue.number), title: issue.title })}
295 className="issue-board-card cursor-pointer space-y-2 rounded-lg bg-shell-surface p-3 shadow-[0_1px_2px_rgb(28_30_34/8%)] outline-none transition-shadow hover:shadow-[0_2px_8px_rgb(28_30_34/14%)] focus-visible:ring-2 focus-visible:ring-shell-tile"
296 >
297 <div className="flex items-start gap-2">
298 <p className="issue-board-card-title min-w-0 flex-1 text-[13px] font-medium leading-5 break-words">
299 <Markdown inline>{issue.title}</Markdown>
300 </p>
301 <PriorityBadge priority={issue.priority} compact />
302 </div>
303 <div className="issue-board-card-meta flex items-center gap-2">
304 <span className="font-mono text-[11px] text-shell-ink-muted">{issueLabel(board, issue.number)}</span>
305 {milestone ? (
306 <span
307 className="truncate text-[11px] text-shell-ink-muted"
308 title={t('issue.board.milestoneTitle', { name: milestone.name })}
309 >
310 {milestone.name}
311 </span>
312 ) : null}
313 <DueChip due={issue.due_at} state={issue.state} />
314 <span className="ml-auto flex items-center gap-1.5">
315 <AvatarStack users={issue.assignees} size={23} />
316 {issue.state === 'closed' ? (
317 <CircleCheck className="h-3.5 w-3.5 text-shell-ink-muted" aria-label={t('label.issueState.closed')} />
318 ) : null}
319 </span>
320 </div>
321 </article>
322 )
323}
324
325/* --------------------------------------------------------------- list --- */
326
327/**
328 * Linear-style rows: priority mark, state mark, key, title, then the trailing
329 * facts (milestone, assignees, updated). No header, no cell borders, no card —
330 * hover is the only container, so the page reads as one list of issues.
331 */
332function IssueList({ boardRef, rows, data, onOpenIssue, onChanged }: IssueBoardBodyProps & { rows: IssueListItem[] }) {
333 const { t } = useT()
334 // One shared width for the key column: a long prefix (APACHE-17) must not
335 // wrap, and every row still starts its title at the same x.
336 const keyChars = Math.max(4, ...rows.map((issue) => issueLabel(data.board.data, issue.number).length))
337 return (
338 <div className="issue-list" role="list" aria-label={t('issue.board.tableCaption')}>
339 {rows.map((issue) => {
340 const milestone = data.milestones.data?.items.find((item) => item.id === issue.milestone_id)
341 const open = () => onOpenIssue(issueRef(data.board.data, issue.number))
342 return (
343 <div key={issue.id} role="listitem" className="issue-list-row" onClick={open}>
344 {/* The priority mark is a control inside a clickable row: its own
345 clicks must not fall through and open the issue. */}
346 <span className="issue-list-priority" onClick={(event) => event.stopPropagation()}>
347 <FilterMenu
348 label={t('issue.board.filter.priority')}
349 iconOnly
350 leading={<PriorityGlyph priority={issue.priority} />}
351 value={issue.priority}
352 options={PRIORITIES.map((meta) => ({ value: meta.id, label: meta.label, color: meta.color }))}
353 onChange={async (value) => {
354 await updateIssue(boardRef, issue.id, { priority: value as IssuePriority }).catch(() => undefined)
355 onChanged()
356 }}
357 />
358 </span>
359 <span className="issue-list-state" data-state={issue.state} title={STATES[issue.state]?.label}>
360 {issue.state === 'closed' ? (
361 <CircleCheck className="h-3.5 w-3.5" aria-hidden />
362 ) : (
363 <Circle className="h-3.5 w-3.5" aria-hidden />
364 )}
365 <span className="sr-only">{STATES[issue.state]?.label}</span>
366 </span>
367 <span className="issue-list-key" style={{ minWidth: keyChars + 'ch' }}>
368 {issueLabel(data.board.data, issue.number)}
369 </span>
370 <button
371 type="button"
372 className="issue-list-title"
373 title={issue.title}
374 onClick={(event) => {
375 event.stopPropagation()
376 open()
377 }}
378 >
379 <Markdown inline>{issue.title}</Markdown>
380 </button>
381 <span className="issue-list-meta">
382 {(issue.labels ?? []).slice(0, 3).map((label) => (
383 <span key={label.id} className="issue-list-chip" title={label.name}>
384 <span
385 className="issue-list-dot"
386 style={{ backgroundColor: normalizeColor(label.color) }}
387 aria-hidden
388 />
389 <span className="issue-list-chip-text">{label.name}</span>
390 </span>
391 ))}
392 {(issue.labels ?? []).length > 3 ? (
393 <span className="issue-list-chip">+{(issue.labels ?? []).length - 3}</span>
394 ) : null}
395 {milestone ? (
396 <span className="issue-list-chip" title={t('issue.board.milestoneTitle', { name: milestone.name })}>
397 <MilestoneIcon className="h-3 w-3 shrink-0" aria-hidden />
398 <span className="issue-list-chip-text">{milestone.name}</span>
399 </span>
400 ) : null}
401 <AvatarStack users={issue.assignees} max={2} size={20} />
402 <span className="issue-list-date" title={issue.updated_at ?? issue.created_at}>
403 {issueStamp(issue.updated_at ?? issue.created_at)}
404 </span>
405 </span>
406 </div>
407 )
408 })}
409 </div>
410 )
411}
412
413/* --------------------------------------------------------------- misc --- */
414
415function Pager({
416 view,
417 total,
418 limit,
419 onFilter,
420}: {
421 view: IssueBoardView
422 total: number
423 limit: number
424 onFilter: (updates: Partial<IssueBoardView>) => void
425}) {
426 const { t, tc } = useT()
427 const pages = Math.max(1, Math.ceil(total / limit))
428 if (pages <= 1) return null
429 const page = Math.min(view.page, pages)
430 return (
431 <div className="flex shrink-0 items-center justify-end gap-2 text-[12.5px] text-shell-ink-muted">
432 <span>{tc('issue.board.pager', total, { page, pages, total })}</span>
433 <button
434 type="button"
435 disabled={page <= 1}
436 onClick={() => onFilter({ page: page - 1 })}
437 className="rounded-md border-2 border-shell-hairline bg-shell-field px-2 py-1 transition-colors hover:border-shell-ink disabled:opacity-40"
438 >
439 {t('issue.board.prev')}
440 </button>
441 <button
442 type="button"
443 disabled={page >= pages}
444 onClick={() => onFilter({ page: page + 1 })}
445 className="rounded-md border-2 border-shell-hairline bg-shell-field px-2 py-1 transition-colors hover:border-shell-ink disabled:opacity-40"
446 >
447 {t('issue.board.next')}
448 </button>
449 </div>
450 )
451}
452
453/** Public so the route layer can hold the frame while a preference decides the layout. */
454export function BoardSkeleton({ columns }: { columns: number }) {
455 const { t } = useT()
456 return (
457 <div
458 className="issue-board-columns issue-board-skeleton flex min-h-0 flex-1 gap-3"
459 role="status"
460 aria-label={t('issue.board.loading')}
461 aria-busy
462 >
463 {Array.from({ length: columns }, (_, index) => (
464 <section
465 key={index}
466 className="issue-board-column flex h-full w-[266px] shrink-0 flex-col gap-2 rounded-lg bg-shell-board p-2.5"
467 >
468 <div className="h-6 w-24 rounded-full bg-shell-active" />
469 {Array.from({ length: 3 }, (_, card) => (
470 <div key={card} className="space-y-2 rounded-lg bg-shell-surface p-3">
471 <div className="h-4 rounded-full bg-shell-active" />
472 <div className="h-4 w-2/3 rounded-full bg-shell-active" />
473 </div>
474 ))}
475 </section>
476 ))}
477 </div>
478 )
479}
480
481function EmptyNote({
482 title,
483 hint,
484 action,
485}: {
486 title: string
487 hint: string
488 action?: { label: string; onClick: () => void }
489}) {
490 return (
491 <div className="issue-board-empty flex w-full flex-col items-center gap-2 rounded-lg border-2 border-dashed border-shell-hairline bg-shell-board px-4 py-16 text-center">
492 <span className="issue-board-empty-icon">
493 <Inbox className="h-7 w-7" strokeWidth={1.6} aria-hidden />
494 </span>
495 <p className="text-[14px] font-semibold">{title}</p>
496 <p className="max-w-sm text-[12.5px] text-shell-ink-muted">{hint}</p>
497 {action ? (
498 <button
499 type="button"
500 onClick={action.onClick}
501 className="mt-2 inline-flex items-center gap-2 rounded-md bg-shell-action px-3.5 py-1.5 text-[13px] font-semibold text-white transition-[filter] hover:brightness-95"
502 >
503 {action.label}
504 <ArrowRight className="h-4 w-4" aria-hidden />
505 </button>
506 ) : null}
507 </div>
508 )
509}
Added view/app/src/domains/issues/ui/issue-detail.tsx view/app/src/domains/issues/ui/issue-detail.tsx +1418 −0 View view/app/src/domains/issues/ui/issue-detail.tsx
@@ -0,0 +1,1418 @@
1/**
2 * One issue, opened from the board (`?issue=42`): body, sidebar fields, comment
3 * thread and the activity line. Every control writes through the real endpoint
4 * and refreshes only this view's data — no document reload, and the board
5 * behind it keeps its filters.
6 */
7
8import { useResourceEvents } from '@app/services/realtime/topic'
9import { ErrorPanel, Markdown } from '@app/components'
10import {
11 type BoardRef,
12 bindLabel,
13 closeIssue,
14 createComment,
15 dateApiValue,
16 dateInputValue,
17 deleteComment,
18 deleteIssue,
19 deleteIssueAttachment,
20 eventLabel,
21 getIssue,
22 type IssueComment,
23 issueAttachmentUrl,
24 issueLabel,
25 listCommentTree,
26 listEvents,
27 listIssueAttachments,
28 listIssueLabels,
29 normalizeColor,
30 PRIORITIES,
31 reopenIssue,
32 setIssueWatch,
33 type UserDto,
34 unbindLabel,
35 updateComment,
36 updateIssue,
37 type WatchMode,
38} from '@app/domains/issues/model'
39import { type IssuePullLink, linkIssuePull, listIssuePulls, listPulls, unlinkIssuePull } from '@app/domains/pulls/model'
40import { listSpaceRepos } from '@app/domains/repo/model'
41import { parseDateValue, t as tr, useT } from '@app/i18n'
42import { fmtTime, formatBytes, issueKey, spaceReposPickKey, useAsync } from '@app/lib'
43import {
44 ArrowLeft,
45 ArrowUp,
46 Bell,
47 Calendar,
48 Check,
49 ChevronDown,
50 FileText,
51 Flag,
52 FolderGit2,
53 GitPullRequest,
54 Loader2,
55 Milestone as MilestoneIcon,
56 MoreHorizontal,
57 Pencil,
58 Plus,
59 Users,
60 X,
61} from 'lucide-react'
62import { type ReactNode, useEffect, useRef, useState } from 'react'
63import type { BoardData } from '../model/use-board-data'
64import { AvatarStack, DangerButton, FilterMenu, LabelChip, PriorityBadge, StateBadge, UserAvatar } from './parts'
65
66const WATCH_LABEL_KEYS: Record<WatchMode, 'issue.watch.watching' | 'issue.watch.unwatched' | 'issue.watch.ignore'> = {
67 watching: 'issue.watch.watching',
68 unwatched: 'issue.watch.unwatched',
69 ignore: 'issue.watch.ignore',
70}
71
72type IssueEvent = import('@app/domains/issues/model').IssueEvent
73
74/**
75 * Entries of the merged activity stream: change events + comment threads.
76 * A run of the same change by the same person collapses into one entry, so
77 * "调整了里程碑 ×4" reads as one line with a count instead of four rows.
78 */
79type FeedEntry =
80 | { kind: 'events'; key: string; at: number; events: IssueEvent[] }
81 | { kind: 'comment'; key: string; at: number; node: import('@app/domains/issues/model').CommentNode }
82
83/** Timeline rows shown before the "show earlier" toggle appears. */
84const FEED_WINDOW = 6
85
86/** Sort key that tolerates the `+00:00:00` offsets Rust emits. */
87function feedStamp(value: string): number {
88 const ms = parseDateValue(value).getTime()
89 return Number.isNaN(ms) ? 0 : ms
90}
91
92/** `09-23` this year, `2025-09-23` otherwise — a timeline stays scannable. */
93function fmtDay(value: string): string {
94 const date = parseDateValue(value)
95 if (Number.isNaN(date.getTime())) return ''
96 const pad = (n: number) => String(n).padStart(2, '0')
97 const day = `${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
98 return date.getFullYear() === new Date().getFullYear() ? day : `${date.getFullYear()}-${day}`
99}
100
101export interface IssueDetailProps {
102 boardRef: BoardRef
103 /** Board-scoped number or uuid, straight from the route. */
104 issueId: string
105 /** Bumped by the page after a board-level change; refetches this issue. */
106 reloadKey: number
107 data: BoardData
108 viewerId?: number
109 onBack: () => void
110 onChanged: () => void
111 /** Open the edit dialog prefilled with this issue. */
112 onEdit: (issue: Awaited<ReturnType<typeof getIssue>>) => void
113}
114
115const ISSUE_TAGS = [
116 'issue-create',
117 'title-update',
118 'description-update',
119 'content-update',
120 'milestone-update',
121 'priority-update',
122 'due-update',
123 'issue-close',
124 'issue-reopen',
125 'comment-create',
126 'comment-update',
127 'comment-delete',
128 'member-create',
129 'member-delete',
130 'label-create',
131 'label-delete',
132 'dependency-create',
133 'dependency-delete',
134 'pin-create',
135 'pin-delete',
136 'repo-relation-create',
137 'repo-relation-delete',
138 'pull-link-create',
139 'pull-link-delete',
140] as const
141
142export function IssueDetail({
143 boardRef,
144 issueId,
145 reloadKey,
146 data,
147 viewerId,
148 onBack,
149 onChanged,
150 onEdit,
151}: IssueDetailProps) {
152 const { t, tc } = useT()
153 const [retry, setRetry] = useState(0)
154 const [notice, setNotice] = useState<string | null>(null)
155 const [feedExpanded, setFeedExpanded] = useState(false)
156 // `retry` is this view's own refresh, `reloadKey` the page's (another tab of
157 // the app changed the board, or a label was renamed in the manage dialog).
158 const stamp = [boardRef.space, boardRef.board, issueId, retry, reloadKey]
159 const detail = useAsync(() => getIssue(boardRef, issueId), stamp, {
160 cacheKey: issueKey(boardRef.space, boardRef.board, issueId, 'detail'),
161 })
162 const labels = useAsync(() => listIssueLabels(boardRef, issueId), stamp, {
163 cacheKey: issueKey(boardRef.space, boardRef.board, issueId, 'labels'),
164 })
165 const comments = useAsync(() => listCommentTree(boardRef, issueId), stamp, {
166 cacheKey: issueKey(boardRef.space, boardRef.board, issueId, 'comments'),
167 })
168 const events = useAsync(() => listEvents(boardRef, issueId), stamp, {
169 cacheKey: issueKey(boardRef.space, boardRef.board, issueId, 'events'),
170 })
171 const attachments = useAsync(() => listIssueAttachments(boardRef, issueId), stamp, {
172 cacheKey: issueKey(boardRef.space, boardRef.board, issueId, 'attachments'),
173 })
174 const issueUuid = detail.data?.id
175 useResourceEvents({
176 topic: 'gitdata-issue',
177 tags: ISSUE_TAGS,
178 filter: issueUuid ? { issue_id: issueUuid } : undefined,
179 enabled: Boolean(issueUuid),
180 onEvent: () => {
181 detail.reloadSilently()
182 labels.reloadSilently()
183 comments.reloadSilently()
184 events.reloadSilently()
185 attachments.reloadSilently()
186 },
187 })
188 const issue = detail.data
189 const failure = detail.error as { code?: number; message?: string } | undefined
190
191 useEffect(() => {
192 if (!notice) return
193 const timer = setTimeout(() => setNotice(null), 4000)
194 return () => clearTimeout(timer)
195 }, [notice])
196
197 if (failure && !issue) {
198 return (
199 <div className="rounded-lg border-2 border-shell-hairline bg-shell-surface p-4">
200 <p className="mb-2 flex items-center gap-2 text-[13px] text-shell-ink-muted">
201 <button
202 type="button"
203 onClick={onBack}
204 className="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-shell-active"
205 >
206 <ArrowLeft className="h-3.5 w-3.5" aria-hidden /> {t('issue.board.backToBoard')}
207 </button>
208 </p>
209 <ErrorPanel
210 status={typeof failure.code === 'number' ? failure.code : 0}
211 what={t('issue.board.errorIssue')}
212 note={failure.message}
213 onRetry={() => setRetry((v) => v + 1)}
214 />
215 </div>
216 )
217 }
218
219 if (!issue) {
220 return (
221 <div className="flex items-center gap-2 rounded-lg border-2 border-shell-hairline bg-shell-surface px-4 py-10 text-[13px] text-shell-ink-muted">
222 <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> {t('issue.board.loadingIssue')}
223 </div>
224 )
225 }
226
227 /** Run a mutation, surface its failure inline, and refresh this issue + board. */
228 async function run(action: () => Promise<unknown>, success?: string) {
229 setNotice(null)
230 try {
231 await action()
232 if (success) setNotice(success)
233 setRetry((v) => v + 1)
234 onChanged()
235 return true
236 } catch (err) {
237 setNotice(
238 (err as Error)?.message
239 ? t('issue.board.actionFailed', { reason: (err as Error).message })
240 : t('issue.board.actionFailedGeneric'),
241 )
242 return false
243 }
244 }
245
246 const boundLabels = labels.data ?? []
247 const attachmentRows = attachments.data ?? []
248 const milestoneOptions = (data.milestones.data?.items ?? []).filter((m) => !m.is_closed)
249 const milestoneName = issue.milestone_id
250 ? (milestoneOptions.find((m) => m.id === issue.milestone_id)?.name ?? t('issue.board.milestoneUnknown'))
251 : ''
252
253 // Linear reads one issue as a single stream: comments and change events share
254 // a timeline. A comment's own event is dropped when its card is already in
255 // the stream, so the thread is never shown twice.
256 const rootNodes = comments.data ?? []
257 const commentIds = new Set<string>()
258 const collectCommentIds = (node: import('@app/domains/issues/model').CommentNode) => {
259 commentIds.add(node.comment.id)
260 node.replies?.forEach(collectCommentIds)
261 }
262 rootNodes.forEach(collectCommentIds)
263 const eventItems = (events.data?.items ?? []).filter(
264 (event) =>
265 !(
266 event.comment_id &&
267 commentIds.has(event.comment_id) &&
268 (event.event_type === 'comment_created' || event.event_type === 'comment_updated')
269 ),
270 )
271 const rawFeed: FeedEntry[] = [
272 ...rootNodes.map((node) => ({
273 kind: 'comment' as const,
274 key: `c-${node.comment.id}`,
275 at: feedStamp(node.comment.created_at),
276 node,
277 })),
278 ...eventItems.map((event) => ({
279 kind: 'events' as const,
280 key: `e-${event.id}`,
281 at: feedStamp(event.created_at),
282 events: [event],
283 })),
284 ].sort((a, b) => a.at - b.at)
285 // Collapse adjacent events of the same type by the same actor.
286 const feed: FeedEntry[] = []
287 for (const entry of rawFeed) {
288 const previous = feed.at(-1)
289 const head = entry.kind === 'events' ? entry.events[0] : null
290 const previousHead = previous?.kind === 'events' ? previous.events[0] : null
291 const sameRun =
292 head &&
293 previousHead &&
294 head.event_type === previousHead.event_type &&
295 (head.user?.user_id ?? '') === (previousHead.user?.user_id ?? '')
296 if (sameRun && previous?.kind === 'events') {
297 previous.events.push(head!)
298 previous.at = entry.at
299 continue
300 }
301 feed.push(entry)
302 }
303 const earlier = Math.max(0, feed.length - FEED_WINDOW)
304 // Collapsing a single row hides more than it explains: only fold real runs.
305 const collapsible = earlier > 1
306 const visibleFeed = feedExpanded || !collapsible ? feed : feed.slice(-FEED_WINDOW)
307
308 return (
309 <div className="issue-detail">
310 {notice ? (
311 <p role="status" className="issue-detail-notice">
312 {notice}
313 </p>
314 ) : null}
315
316 <div className="issue-detail-topline">
317 <button type="button" onClick={onBack} className="issue-action">
318 <ArrowLeft className="h-3.5 w-3.5" aria-hidden /> {t('issue.board.backToBoard')}
319 </button>
320 <div className="issue-detail-actions">
321 <button type="button" onClick={() => onEdit(issue)} className="issue-action">
322 <Pencil className="h-3.5 w-3.5" aria-hidden /> {t('issue.board.edit')}
323 </button>
324 {issue.state === 'open' ? (
325 <button
326 type="button"
327 onClick={() => void run(() => closeIssue(boardRef, issue.id), t('issue.board.closedIssue'))}
328 className="issue-action issue-action-strong"
329 >
330 {t('issue.board.closeIssue')}
331 </button>
332 ) : (
333 <button
334 type="button"
335 onClick={() => void run(() => reopenIssue(boardRef, issue.id), t('issue.board.reopenedIssue'))}
336 className="issue-action issue-action-strong"
337 >
338 {t('issue.board.reopen')}
339 </button>
340 )}
341 {/* Deleting is rare and irreversible: it lives behind ⋯ with a
342 two-step confirm instead of sitting next to 关闭工单. */}
343 <IssueMoreMenu onDelete={() => void deleteAndBack(issue.id)} />
344 </div>
345 </div>
346
347 <div className="issue-detail-layout">
348 <div className="issue-detail-main">
349 <header className="issue-detail-header">
350 <p className="issue-detail-key">
351 <span className="issue-detail-number">{issueLabel(data.board.data, issue.number)}</span>
352 <StateBadge state={issue.state} />
353 <PriorityBadge priority={issue.priority} />
354 </p>
355 <h1 className="issue-detail-title">
356 <Markdown inline>{issue.title}</Markdown>
357 </h1>
358 {issue.description ? <Markdown className="issue-detail-desc">{issue.description}</Markdown> : null}
359 {issue.content ? <Markdown className="issue-detail-desc">{issue.content}</Markdown> : null}
360 {!issue.description && !issue.content ? (
361 <p className="issue-detail-desc is-empty">{t('issue.board.noBody')}</p>
362 ) : null}
363 <p className="issue-detail-byline">
364 {issue.author ? <UserAvatar user={issue.author} size={20} /> : null}
365 <span>
366 {t('issue.board.createdAt', {
367 author: issue.author?.display_name ?? t('issue.board.unknownAuthor'),
368 time: fmtTime(issue.created_at),
369 })}
370 </span>
371 {issue.updated_at && issue.updated_at !== issue.created_at ? (
372 <span>· {t('issue.board.updatedAt', { time: fmtTime(issue.updated_at) })}</span>
373 ) : null}
374 {issue.closed_at ? <span>· {t('issue.board.closedAt', { time: fmtTime(issue.closed_at) })}</span> : null}
375 </p>
376 {attachmentRows.length > 0 ? (
377 <div className="issue-attachments">
378 {attachmentRows.map((row) => {
379 const url = issueAttachmentUrl(row.id)
380 const isImage = row.content_type.startsWith('image/')
381 const mine = Boolean(viewerId && row.uploader_id === viewerId)
382 const open = () => window.open(url, '_blank', 'noopener,noreferrer')
383 return (
384 <span key={row.id} className={`issue-attachment ${isImage ? 'is-image' : 'is-file'}`}>
385 {isImage ? (
386 <button
387 type="button"
388 className="issue-attachment-image"
389 onClick={open}
390 aria-label={t('issue.board.attachment.open', { name: row.filename })}
391 title={row.filename}
392 >
393 <img src={url} alt={row.filename} loading="lazy" />
394 </button>
395 ) : (
396 <button
397 type="button"
398 className="issue-attachment-link"
399 onClick={open}
400 aria-label={t('issue.board.attachment.open', { name: row.filename })}
401 title={row.filename}
402 >
403 <FileText className="h-3.5 w-3.5" aria-hidden />
404 <span className="issue-attachment-name">{row.filename}</span>
405 <span className="issue-attachment-size">{formatBytes(row.size)}</span>
406 </button>
407 )}
408 {mine ? (
409 <button
410 type="button"
411 className="issue-attachment-remove"
412 aria-label={t('issue.board.form.removeAttachment', { name: row.filename })}
413 title={t('issue.board.form.removeAttachment', { name: row.filename })}
414 onClick={() => void removeAttachment(row.id)}
415 >
416 <X className="h-3 w-3" aria-hidden />
417 </button>
418 ) : null}
419 </span>
420 )
421 })}
422 </div>
423 ) : null}
424 </header>
425
426 <section className="issue-feed">
427 <div className="issue-feed-head">
428 <h2>{t('issue.board.activity')}</h2>
429 <span className="issue-feed-count">{tc('issue.board.activityCount', events.data?.total ?? 0)}</span>
430 </div>
431
432 {comments.loading && !comments.data ? (
433 <p className="issue-feed-note">{t('issue.board.loadingComments')}</p>
434 ) : null}
435 {comments.error ? <p className="issue-feed-note">{t('issue.board.commentsFailed')}</p> : null}
436 {events.error ? <p className="issue-feed-note">{t('issue.board.activityFailed')}</p> : null}
437
438 {collapsible ? (
439 <button
440 type="button"
441 className="issue-feed-more"
442 aria-expanded={feedExpanded}
443 onClick={() => setFeedExpanded((v) => !v)}
444 >
445 <ChevronDown
446 className={`h-3.5 w-3.5 transition-transform ${feedExpanded ? 'rotate-180' : ''}`}
447 aria-hidden
448 />
449 {feedExpanded ? t('issue.board.hideEarlier') : t('issue.board.showEarlier', { count: earlier })}
450 </button>
451 ) : null}
452
453 {visibleFeed.length === 0 && !comments.loading && !events.loading ? (
454 <p className="issue-feed-note">{t('issue.board.noActivity')}</p>
455 ) : null}
456
457 {visibleFeed.map((entry) =>
458 entry.kind === 'comment' ? (
459 <CommentCard
460 key={entry.key}
461 node={entry.node}
462 boardRef={boardRef}
463 issueId={issue.id}
464 viewerId={viewerId}
465 onReload={() => setRetry((v) => v + 1)}
466 />
467 ) : (
468 <EventRow
469 key={entry.key}
470 events={entry.events}
471 memberName={(id) =>
472 (data.members.data?.items ?? [])
473 .map((item) => item.user)
474 .find((user) => String(user?.user_id) === id)?.display_name ?? undefined
475 }
476 />
477 ),
478 )}
479
480 <CommentComposer boardRef={boardRef} issueId={issue.id} onReload={() => setRetry((v) => v + 1)} />
481 </section>
482 </div>
483
484 <aside className="issue-detail-rail">
485 <div className="issue-rail-group">
486 <p className="issue-rail-title">{t('issue.board.properties')}</p>
487 <div className="issue-rail-rows">
488 <FilterMenu
489 bare
490 label={t('issue.board.filter.priority')}
491 leading={
492 <>
493 <Flag className="h-3.5 w-3.5" aria-hidden />
494 <span>{t('issue.board.filter.priority')}</span>
495 </>
496 }
497 value={issue.priority}
498 options={PRIORITIES.map((meta) => ({ value: meta.id, label: meta.label, color: meta.color }))}
499 onChange={(value) => void run(() => updateIssue(boardRef, issue.id, { priority: value as never }))}
500 />
501 <FilterMenu
502 bare
503 label={t('issue.board.filter.milestone')}
504 leading={
505 <>
506 <MilestoneIcon className="h-3.5 w-3.5" aria-hidden />
507 <span>{t('issue.board.filter.milestone')}</span>
508 </>
509 }
510 value={issue.milestone_id ?? ''}
511 active={milestoneName || undefined}
512 placeholder={t('issue.board.unset')}
513 options={milestoneOptions.map((m) => ({ value: m.id, label: m.name }))}
514 onChange={(value) => void run(() => updateIssue(boardRef, issue.id, { milestone_id: value || null }))}
515 />
516 {/* DueProp 自带 .issue-prop-row:再包一层会多 8px 内边距,右对齐差一行 */}
517 <DueProp
518 value={issue.due_at}
519 onChange={(next) => void run(() => updateIssue(boardRef, issue.id, { due_at: next }))}
520 />
521 <AssigneeMenu
522 members={data.members.data?.items ?? []}
523 selected={issue.assignees.map((user) => user.user_id)}
524 onChange={(ids) => void run(() => updateIssue(boardRef, issue.id, { assignee_ids: ids }))}
525 />
526 {/* Subscription is an issue property, not a feed control. */}
527 <FilterMenu
528 bare
529 label={t('issue.board.notifyField')}
530 leading={
531 <>
532 <Bell className="h-3.5 w-3.5" aria-hidden />
533 <span>{t('issue.board.notifyField')}</span>
534 </>
535 }
536 value={issue.viewer_watch ?? 'unwatched'}
537 active={t(WATCH_LABEL_KEYS[issue.viewer_watch ?? 'unwatched'])}
538 options={(Object.keys(WATCH_LABEL_KEYS) as WatchMode[]).map((mode) => ({
539 value: mode,
540 label: t(WATCH_LABEL_KEYS[mode]),
541 }))}
542 onChange={(value) => void run(() => setIssueWatch(boardRef, issue.id, value as WatchMode))}
543 />
544 </div>
545 </div>
546
547 <div className="issue-rail-group">
548 <p className="issue-rail-title">{t('issue.board.labelsField')}</p>
549 <div className="issue-rail-labels">
550 {boundLabels.map((label) => (
551 <LabelChip
552 key={label.id}
553 label={label}
554 onRemove={() => void run(() => unbindLabel(boardRef, issue.id, label.id))}
555 />
556 ))}
557 {boundLabels.length === 0 ? (
558 <span className="issue-rail-empty">
559 {labels.error ? t('issue.board.labelsFailed') : t('issue.board.noLabels')}
560 </span>
561 ) : null}
562 <LabelBinder
563 labels={data.labels.data?.items ?? []}
564 bound={boundLabels.map((label) => label.id)}
565 onToggle={(labelId, on) =>
566 void run(() =>
567 on ? bindLabel(boardRef, issue.id, labelId) : unbindLabel(boardRef, issue.id, labelId),
568 )
569 }
570 />
571 </div>
572 </div>
573
574 <div className="issue-rail-group">
575 <p className="issue-rail-title">{t('issue.board.linkedPulls')}</p>
576 <IssuePullLinks boardRef={boardRef} issueId={issue.id} onChanged={() => void onChanged()} />
577 </div>
578 </aside>
579 </div>
580 </div>
581 )
582
583 async function deleteAndBack(id: string) {
584 try {
585 await deleteIssue(boardRef, id)
586 onChanged()
587 onBack()
588 } catch (err) {
589 setNotice(t('issue.board.deleteFailed', { reason: (err as Error)?.message ?? t('common.state.unknown') }))
590 }
591 }
592
593 async function removeAttachment(id: string) {
594 try {
595 await deleteIssueAttachment(id)
596 setRetry((v) => v + 1)
597 } catch (err) {
598 setNotice(t('issue.board.deleteFailed', { reason: (err as Error)?.message ?? t('common.state.unknown') }))
599 }
600 }
601}
602
603/**
604 * 截止日期:和优先级/里程碑/负责人一样是「点开选择器」的属性行,未设置时
605 * 保持灰字。`<input type="date">` 本体只用来看/收值,日历由 showPicker 打开。
606 */
607function DueProp({ value, onChange }: { value: string | null | undefined; onChange: (next: string | null) => void }) {
608 const { t } = useT()
609 const input = useRef<HTMLInputElement | null>(null)
610 const label = value ? fmtTime(value) : t('issue.board.unset')
611 return (
612 <div className="issue-prop-row issue-prop-row-action">
613 <button
614 type="button"
615 className="issue-prop-target"
616 onClick={() => {
617 const field = input.current
618 if (!field) return
619 if (typeof field.showPicker === 'function') {
620 try {
621 field.showPicker()
622 return
623 } catch {
624 /* fall through to a plain click when the picker is unavailable */
625 }
626 }
627 field.click()
628 }}
629 >
630 <Calendar className="issue-prop-icon h-3.5 w-3.5" aria-hidden />
631 <span className="issue-prop-label">{t('issue.board.filter.due')}</span>
632 <span className={`issue-prop-value${value ? '' : ' is-empty'}`}>
633 <span className="issue-prop-text">{label}</span>
634 <ChevronDown className="issue-prop-chevron h-3 w-3" aria-hidden />
635 </span>
636 </button>
637 <input
638 ref={input}
639 type="date"
640 aria-label={t('issue.board.filter.due')}
641 value={dateInputValue(value)}
642 onChange={(event) => onChange(dateApiValue(event.target.value))}
643 className="issue-prop-date-sr"
644 />
645 {value ? (
646 <button
647 type="button"
648 className="issue-prop-clear"
649 aria-label={t('issue.board.clearDate')}
650 title={t('issue.board.clearDate')}
651 onClick={() => onChange(null)}
652 >
653 <X className="h-3 w-3" aria-hidden />
654 </button>
655 ) : null}
656 </div>
657 )
658}
659
660/**
661 * 工单侧的 PR 关联面板:列出关联(含 relation id,可解除)并支持新增关联。
662 */
663/** Top-right overflow: the low-frequency, destructive actions of one issue. */
664function IssueMoreMenu({ onDelete }: { onDelete: () => void }) {
665 const { t } = useT()
666 const [open, setOpen] = useState(false)
667 const box = useRef<HTMLDivElement | null>(null)
668 useEffect(() => {
669 if (!open) return
670 const onDown = (event: MouseEvent) => {
671 if (!box.current?.contains(event.target as Node)) setOpen(false)
672 }
673 const onKey = (event: KeyboardEvent) => {
674 if (event.key === 'Escape') setOpen(false)
675 }
676 window.addEventListener('mousedown', onDown)
677 window.addEventListener('keydown', onKey)
678 return () => {
679 window.removeEventListener('mousedown', onDown)
680 window.removeEventListener('keydown', onKey)
681 }
682 }, [open])
683 return (
684 <div ref={box} className="issue-more">
685 <button
686 type="button"
687 className="issue-action"
688 aria-label={t('issue.board.more')}
689 title={t('issue.board.more')}
690 aria-expanded={open}
691 aria-haspopup="menu"
692 onClick={() => setOpen((value) => !value)}
693 >
694 <MoreHorizontal className="h-3.5 w-3.5" aria-hidden />
695 </button>
696 {open ? (
697 <div className="issue-more-menu" role="menu">
698 <DangerButton label={t('issue.board.deleteIssue')} className="issue-more-danger" onConfirm={onDelete} />
699 </div>
700 ) : null}
701 </div>
702 )
703}
704
705function IssuePullLinks({
706 boardRef,
707 issueId,
708 onChanged,
709}: {
710 boardRef: BoardRef
711 issueId: string
712 onChanged: () => void
713}) {
714 const { t } = useT()
715 const [tick, setTick] = useState(0)
716 const [repoName, setRepoName] = useState('')
717 const [pullRef, setPullRef] = useState('')
718 // The picker is a response to "+ 关联", not permanent furniture: an issue
719 // with no links should read as one quiet row, not a three-step form.
720 const [adding, setAdding] = useState(false)
721 const [error, setError] = useState<string | null>(null)
722 const [busy, setBusy] = useState(false)
723 const pulls = useAsync(
724 () => listIssuePulls(boardRef.space, boardRef.board, issueId),
725 [boardRef.space, boardRef.board, issueId, tick],
726 )
727 // 仓库从这里选,不再靠手打:社区里的仓库名是唯一合法输入,列出来才能避免拼错。
728 const repos = useAsync(() => listSpaceRepos(boardRef.space), [boardRef.space], {
729 cacheKey: spaceReposPickKey(boardRef.space),
730 })
731 const rows = pulls.data ?? []
732 const repoOptions = (repos.data ?? []).map((repo) => ({ value: repo.name, label: repo.name }))
733 // The PR is picked from the chosen repo's own list for the same reason the
734 // repo is: `pull_ref` is matched against the repo's numbering, not typed.
735 // No repo chosen yet means no list to offer, so the query stays idle.
736 const openPulls = useAsync(
737 () =>
738 repoName
739 ? listPulls(boardRef.space, repoName, { state: 'open', limit: 50 })
740 : Promise.resolve({ items: [], total: 0, limit: 0, offset: 0 }),
741 [boardRef.space, repoName],
742 )
743 const pullOptions = (openPulls.data?.items ?? []).map((pull) => ({
744 value: String(pull.number),
745 label: `#${pull.number} ${pull.title}`,
746 }))
747
748 async function link() {
749 if (!repoName.trim() || !pullRef.trim()) return
750 setBusy(true)
751 setError(null)
752 try {
753 await linkIssuePull(boardRef.space, boardRef.board, issueId, {
754 space_name: boardRef.space,
755 repo_name: repoName.trim(),
756 pull_ref: pullRef.trim(),
757 })
758 setPullRef('')
759 setAdding(false)
760 setTick((t) => t + 1)
761 onChanged()
762 } catch (f) {
763 setError(f instanceof Error ? f.message : t('issue.board.linkFailed'))
764 } finally {
765 setBusy(false)
766 }
767 }
768
769 return (
770 <div className="issue-pull-links">
771 {rows.map((link: IssuePullLink) => (
772 <div key={link.relation_id} className="issue-pull-card">
773 <div className="issue-pull-card-head">
774 <GitPullRequest className="issue-prop-icon h-3.5 w-3.5" aria-hidden />
775 <span className="issue-pull-card-title" title={link.pull.title}>
776 #{link.pull.number} {link.pull.title}
777 </span>
778 <span className="issue-pull-state" data-state={link.pull.state}>
779 {link.pull.state === 'open'
780 ? t('issue.board.stateOpen')
781 : link.pull.state === 'merged'
782 ? t('pull.state.merged')
783 : t('pull.state.closed')}
784 </span>
785 </div>
786 <div className="issue-pull-card-foot">
787 {/* 两个分支各自截断,箭头保持不动:长分支名不会把箭头挤到中间。 */}
788 <span className="issue-pull-branch" title={link.pull.head.branch}>
789 {link.pull.head.branch}
790 </span>
791 <span className="issue-pull-arrow" aria-hidden>
792 →
793 </span>
794 <span className="issue-pull-branch issue-pull-branch-base" title={link.pull.base.branch}>
795 {link.pull.base.branch}
796 </span>
797 </div>
798 <button
799 type="button"
800 aria-label={t('issue.board.unlink')}
801 title={t('issue.board.unlink')}
802 className="issue-prop-clear issue-pull-card-remove"
803 onClick={() =>
804 void unlinkIssuePull(boardRef.space, boardRef.board, issueId, link.relation_id)
805 .then(() => {
806 setTick((t) => t + 1)
807 // 解除关联也是一条活动记录(后端会写 pull_unlinked),刷新整页数据。
808 onChanged()
809 })
810 .catch(() => undefined)
811 }
812 >
813 <X className="h-3 w-3" aria-hidden />
814 </button>
815 </div>
816 ))}
817 {rows.length === 0 ? <p className="issue-rail-empty">{t('issue.board.noPullLinks')}</p> : null}
818 {adding ? (
819 <div className="issue-pull-picker">
820 {/*
821 A repo is addressed by its exact name, so it is picked from the
822 space's own repos; the PR then comes from that repo's numbering.
823 */}
824 {repoOptions.length > 0 ? (
825 <FilterMenu
826 bare
827 label={t('issue.board.repoPlaceholder')}
828 placeholder={t('issue.board.pickRepo')}
829 leading={<FolderGit2 className="h-3.5 w-3.5" aria-hidden />}
830 value={repoName}
831 active={repoName || undefined}
832 options={repoOptions}
833 onChange={(next) => {
834 setRepoName(next)
835 // A PR number only means something inside one repo.
836 setPullRef('')
837 }}
838 />
839 ) : (
840 <span className="issue-rail-empty">{repos.loading ? t('ui.loading') : t('issue.board.noRepos')}</span>
841 )}
842 {repoName ? (
843 pullOptions.length > 0 ? (
844 <FilterMenu
845 bare
846 label={t('issue.board.pullPlaceholder')}
847 placeholder={t('issue.board.pickPull')}
848 leading={<GitPullRequest className="h-3.5 w-3.5" aria-hidden />}
849 value={pullRef}
850 active={pullOptions.find((option) => option.value === pullRef)?.label}
851 options={pullOptions}
852 onChange={setPullRef}
853 />
854 ) : (
855 <span className="issue-rail-empty">
856 {openPulls.loading ? t('ui.loading') : t('issue.board.noOpenPulls')}
857 </span>
858 )
859 ) : null}
860 <div className="issue-pull-picker-foot">
861 <button
862 type="button"
863 className="issue-link-button"
864 disabled={busy || !repoName || !pullRef}
865 onClick={() => void link()}
866 >
867 {t('issue.board.link')}
868 </button>
869 <button type="button" className="issue-pull-cancel" onClick={() => setAdding(false)}>
870 {t('common.action.cancel')}
871 </button>
872 </div>
873 </div>
874 ) : (
875 <button type="button" className="issue-prop-add" onClick={() => setAdding(true)}>
876 <Plus className="h-3.5 w-3.5" aria-hidden /> {t('issue.board.linkPull')}
877 </button>
878 )}
879 {error ? <p className="issue-rail-error">{error}</p> : null}
880 </div>
881 )
882}
883
884/**
885 * 负责人:多选。收起时是一行属性(头像 + 名字),展开后是成员勾选清单。
886 */
887function AssigneeMenu({
888 members,
889 selected,
890 onChange,
891}: {
892 members: { user?: import('@app/domains/issues/model').UserDto | null }[]
893 selected: number[]
894 onChange: (ids: number[]) => void
895}) {
896 const { t } = useT()
897 const [open, setOpen] = useState(false)
898 const box = useRef<HTMLDivElement | null>(null)
899
900 useEffect(() => {
901 if (!open) return
902 const onDown = (event: MouseEvent) => {
903 if (box.current && !box.current.contains(event.target as Node)) setOpen(false)
904 }
905 const onKey = (event: KeyboardEvent) => {
906 if (event.key === 'Escape') setOpen(false)
907 }
908 window.addEventListener('mousedown', onDown)
909 window.addEventListener('keydown', onKey)
910 return () => {
911 window.removeEventListener('mousedown', onDown)
912 window.removeEventListener('keydown', onKey)
913 }
914 }, [open])
915
916 const users = members.map((member) => member.user).filter((user): user is UserDto => Boolean(user))
917 const picked = users.filter((user) => selected.includes(user.user_id))
918
919 return (
920 <div ref={box} className="relative">
921 <button
922 type="button"
923 aria-haspopup="listbox"
924 aria-expanded={open}
925 onClick={() => setOpen((v) => !v)}
926 className="issue-prop-row w-full"
927 >
928 <Users className="issue-prop-icon h-3.5 w-3.5" aria-hidden />
929 <span className="issue-prop-label">{t('issue.board.assigneeField')}</span>
930 <span className="issue-prop-value">
931 {picked.length > 0 ? (
932 <>
933 <AvatarStack users={picked} size={18} />
934 {picked.length === 1 ? (
935 <span className="issue-prop-text">{picked[0].display_name || picked[0].username}</span>
936 ) : null}
937 </>
938 ) : (
939 <span className="issue-prop-text is-empty">{t('issue.board.unassigned')}</span>
940 )}
941 <ChevronDown className="issue-prop-chevron h-3 w-3" aria-hidden />
942 </span>
943 </button>
944 {open ? (
945 <div
946 className="board-filter-popover issue-prop-popover"
947 role="listbox"
948 aria-multiselectable="true"
949 aria-label={t('issue.board.assigneeField')}
950 >
951 {users.length === 0 ? (
952 <p className="issue-rail-empty">{t('issue.board.noMembers')}</p>
953 ) : (
954 users.map((user) => {
955 const on = selected.includes(user.user_id)
956 return (
957 <button
958 key={user.user_id}
959 type="button"
960 role="option"
961 aria-selected={on}
962 onClick={() =>
963 onChange(on ? selected.filter((id) => id !== user.user_id) : [...selected, user.user_id])
964 }
965 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"
966 >
967 <span className={`issue-check ${on ? 'is-on' : ''}`} aria-hidden>
968 {on ? <Check className="h-3 w-3" /> : null}
969 </span>
970 <UserAvatar user={user} size={18} />
971 <span className={`min-w-0 flex-1 truncate ${on ? 'font-medium' : ''}`}>
972 {user.display_name || user.username}
973 </span>
974 </button>
975 )
976 })
977 )}
978 </div>
979 ) : null}
980 </div>
981 )
982}
983
984function LabelBinder({
985 labels,
986 bound,
987 onToggle,
988}: {
989 labels: import('@app/domains/issues/model').IssueLabel[]
990 bound: string[]
991 onToggle: (labelId: string, on: boolean) => void
992}) {
993 const [open, setOpen] = useState(false)
994 const box = useRef<HTMLDivElement | null>(null)
995 useEffect(() => {
996 if (!open) return
997 const onDown = (event: MouseEvent) => {
998 if (box.current && !box.current.contains(event.target as Node)) setOpen(false)
999 }
1000 const onKey = (event: KeyboardEvent) => {
1001 if (event.key === 'Escape') setOpen(false)
1002 }
1003 window.addEventListener('mousedown', onDown)
1004 window.addEventListener('keydown', onKey)
1005 return () => {
1006 window.removeEventListener('mousedown', onDown)
1007 window.removeEventListener('keydown', onKey)
1008 }
1009 }, [open])
1010 if (labels.length === 0) return null
1011 const missing = labels.filter((label) => !bound.includes(label.id))
1012 return (
1013 <div ref={box} className="relative">
1014 <button type="button" aria-expanded={open} onClick={() => setOpen((v) => !v)} className="issue-prop-add">
1015 <Plus className="h-3.5 w-3.5" aria-hidden /> {tr('issue.board.addLabel')}
1016 </button>
1017 {open ? (
1018 <div className="board-filter-popover issue-prop-popover" role="listbox" aria-label={tr('issue.board.addLabel')}>
1019 {missing.map((label) => (
1020 <button
1021 key={label.id}
1022 type="button"
1023 role="option"
1024 aria-selected={false}
1025 onClick={() => onToggle(label.id, true)}
1026 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"
1027 >
1028 <span
1029 className="h-2.5 w-2.5 shrink-0 rounded-full"
1030 style={{ backgroundColor: normalizeColor(label.color) }}
1031 aria-hidden
1032 />
1033 <span className="min-w-0 flex-1 truncate">{label.name}</span>
1034 </button>
1035 ))}
1036 {missing.length === 0 ? <p className="issue-rail-empty">{tr('issue.board.allLabelsBound')}</p> : null}
1037 </div>
1038 ) : null}
1039 </div>
1040 )
1041}
1042
1043/* ------------------------------------------- activity feed and comments */
1044
1045/** Bottom composer: a quiet card that reads like the comment it will add. */
1046function CommentComposer({
1047 boardRef,
1048 issueId,
1049 onReload,
1050}: {
1051 boardRef: BoardRef
1052 issueId: string
1053 onReload: () => void
1054}) {
1055 const [draft, setDraft] = useState('')
1056 const [busy, setBusy] = useState(false)
1057 const [error, setError] = useState<string | null>(null)
1058
1059 async function submit() {
1060 const content = draft.trim()
1061 if (!content || busy) return
1062 setBusy(true)
1063 setError(null)
1064 try {
1065 await createComment(boardRef, issueId, content)
1066 setDraft('')
1067 onReload()
1068 } catch (err) {
1069 setError((err as Error)?.message ?? tr('issue.board.commentSendFailed'))
1070 } finally {
1071 setBusy(false)
1072 }
1073 }
1074
1075 return (
1076 <div className="comment-card issue-composer">
1077 <textarea
1078 value={draft}
1079 onChange={(event) => setDraft(event.target.value)}
1080 onKeyDown={(event) => {
1081 if ((event.metaKey || event.ctrlKey) && event.key === 'Enter' && !event.nativeEvent.isComposing) void submit()
1082 }}
1083 rows={2}
1084 placeholder={tr('issue.board.commentPlaceholder')}
1085 aria-label={tr('issue.board.newCommentAria')}
1086 className="comment-input bg-transparent"
1087 />
1088 {error ? (
1089 <p role="alert" className="comment-error">
1090 {error}
1091 </p>
1092 ) : null}
1093 <div className="comment-foot">
1094 {busy ? <Loader2 className="h-3.5 w-3.5 animate-spin text-shell-ink-muted" aria-hidden /> : null}
1095 <button
1096 type="button"
1097 disabled={busy || draft.trim().length === 0}
1098 onClick={() => void submit()}
1099 aria-label={tr('issue.board.submitComment')}
1100 title={tr('issue.board.submitComment')}
1101 className="comment-send"
1102 >
1103 <ArrowUp className="h-4 w-4" aria-hidden />
1104 </button>
1105 </div>
1106 </div>
1107 )
1108}
1109
1110function CommentCard({
1111 node,
1112 boardRef,
1113 issueId,
1114 viewerId,
1115 onReload,
1116}: {
1117 node: import('@app/domains/issues/model').CommentNode
1118 boardRef: BoardRef
1119 issueId: string
1120 viewerId?: number
1121 onReload: () => void
1122}) {
1123 const comment: IssueComment = node.comment
1124 const mine = Boolean(viewerId && comment.poster?.user_id === viewerId)
1125 const [reply, setReply] = useState('')
1126 // The reply box is a response to an intent, not furniture: it appears under
1127 // the 回复 button instead of doubling every comment's footprint.
1128 const [replying, setReplying] = useState(false)
1129 const [editing, setEditing] = useState(false)
1130 const [text, setText] = useState(comment.content)
1131 const [busy, setBusy] = useState(false)
1132 const [error, setError] = useState<string | null>(null)
1133 const replyBox = useRef<HTMLTextAreaElement | null>(null)
1134
1135 useEffect(() => {
1136 if (!replying) return
1137 replyBox.current?.focus()
1138 }, [replying])
1139
1140 async function act(action: () => Promise<unknown>) {
1141 setBusy(true)
1142 setError(null)
1143 try {
1144 await action()
1145 setEditing(false)
1146 onReload()
1147 } catch (err) {
1148 setError((err as Error)?.message ?? tr('issue.board.actionFailedGeneric'))
1149 } finally {
1150 setBusy(false)
1151 }
1152 }
1153
1154 async function submitReply() {
1155 const content = reply.trim()
1156 if (!content || busy) return
1157 setBusy(true)
1158 setError(null)
1159 try {
1160 await createComment(boardRef, issueId, content, comment.id)
1161 setReply('')
1162 setReplying(false)
1163 onReload()
1164 } catch (err) {
1165 setError((err as Error)?.message ?? tr('issue.board.replyFailed'))
1166 } finally {
1167 setBusy(false)
1168 }
1169 }
1170
1171 return (
1172 <article className="comment-card">
1173 <div className="comment-head">
1174 {comment.poster ? <UserAvatar user={comment.poster} size={22} /> : null}
1175 <span className="comment-author">{comment.poster?.display_name ?? tr('common.state.unknownUser')}</span>
1176 <span className="comment-time">{fmtTime(comment.created_at)}</span>
1177 {comment.updated_at && comment.updated_at !== comment.created_at ? (
1178 <span className="comment-time">{tr('issue.board.edited')}</span>
1179 ) : null}
1180 <span className="comment-actions">
1181 {!editing ? (
1182 <button type="button" className="comment-action" onClick={() => setReplying((value) => !value)}>
1183 {tr('issue.board.reply')}
1184 </button>
1185 ) : null}
1186 {mine ? (
1187 <button type="button" onClick={() => setEditing((v) => !v)} className="comment-action">
1188 {editing ? tr('common.action.cancel') : tr('common.action.edit')}
1189 </button>
1190 ) : null}
1191 {mine ? (
1192 <DangerButton
1193 label={tr('common.action.delete')}
1194 busy={busy}
1195 onConfirm={() => void act(() => deleteComment(boardRef, issueId, comment.id))}
1196 />
1197 ) : null}
1198 </span>
1199 </div>
1200 {editing ? (
1201 <div className="comment-editor">
1202 <textarea
1203 value={text}
1204 onChange={(event) => setText(event.target.value)}
1205 rows={3}
1206 aria-label={tr('issue.board.editCommentAria')}
1207 className="comment-input bg-transparent"
1208 />
1209 <div className="flex justify-end">
1210 <button
1211 type="button"
1212 disabled={busy || text.trim().length === 0}
1213 onClick={() => void act(() => updateComment(boardRef, issueId, comment.id, text.trim()))}
1214 className="issue-action issue-action-strong"
1215 >
1216 {tr('common.action.save')}
1217 </button>
1218 </div>
1219 </div>
1220 ) : (
1221 <p className="comment-body">{comment.content}</p>
1222 )}
1223 {error ? (
1224 <p role="alert" className="comment-error">
1225 {error}
1226 </p>
1227 ) : null}
1228 {node.replies?.length ? (
1229 <div className="comment-replies">
1230 {node.replies.map((reply2) => (
1231 <CommentCard
1232 key={reply2.comment.id}
1233 node={reply2}
1234 boardRef={boardRef}
1235 issueId={issueId}
1236 viewerId={viewerId}
1237 onReload={onReload}
1238 />
1239 ))}
1240 </div>
1241 ) : null}
1242 {!editing && replying ? (
1243 <div className="comment-reply-row">
1244 <textarea
1245 ref={replyBox}
1246 value={reply}
1247 onChange={(event) => setReply(event.target.value)}
1248 onKeyDown={(event) => {
1249 if (event.key === 'Escape') {
1250 setReplying(false)
1251 return
1252 }
1253 if (!(event.metaKey || event.ctrlKey) || event.key !== 'Enter' || event.nativeEvent.isComposing) return
1254 event.preventDefault()
1255 void submitReply()
1256 }}
1257 rows={2}
1258 placeholder={tr('issue.board.replyPlaceholder')}
1259 aria-label={tr('issue.board.replyAria', {
1260 name: comment.poster?.display_name ?? tr('issue.board.commentFallback'),
1261 })}
1262 className="comment-reply-input bg-transparent"
1263 />
1264 {busy ? <Loader2 className="h-3.5 w-3.5 animate-spin text-shell-ink-muted" aria-hidden /> : null}
1265 <button
1266 type="button"
1267 disabled={busy || reply.trim().length === 0}
1268 onClick={() => void submitReply()}
1269 aria-label={tr('issue.board.send')}
1270 title={tr('issue.board.send')}
1271 className="comment-send"
1272 >
1273 <ArrowUp className="h-3.5 w-3.5" aria-hidden />
1274 </button>
1275 </div>
1276 ) : null}
1277 </article>
1278 )
1279}
1280
1281/**
1282 * One change on the timeline — or a run of them. The line reads
1283 * `actor 将里程碑 未设置 → v1.0 · 09-23`, with the changed values called out; a
1284 * run keeps its first line and offers the rest behind a count.
1285 */
1286function EventRow({ events, memberName }: { events: IssueEvent[]; memberName: (id: string) => string | undefined }) {
1287 const { t } = useT()
1288 const [open, setOpen] = useState(false)
1289 const first = events[0]
1290 const last = events[events.length - 1]
1291 const grouped = events.length > 1
1292 const rows = grouped && !open ? [first] : events
1293 return (
1294 <div className="issue-event">
1295 <span className="issue-event-icon">
1296 {first.user ? (
1297 <UserAvatar user={first.user} size={20} />
1298 ) : (
1299 <span className="issue-event-dot">
1300 <Users className="h-3 w-3" aria-hidden />
1301 </span>
1302 )}
1303 </span>
1304 <div className="issue-event-stack">
1305 {rows.map((event) => (
1306 <p key={event.id} className="issue-event-body">
1307 <span className="issue-event-actor">{event.user?.display_name ?? t('issue.board.system')}</span>{' '}
1308 {describeEvent(event, memberName, t)}
1309 <span className="issue-event-time" title={fmtTime(event.created_at)}>
1310 {' '}
1311 · {fmtDay(event.created_at)}
1312 </span>
1313 </p>
1314 ))}
1315 {grouped ? (
1316 <button
1317 type="button"
1318 className="issue-event-toggle"
1319 aria-expanded={open}
1320 onClick={() => setOpen((value) => !value)}
1321 >
1322 <ChevronDown className={`h-3 w-3 transition-transform ${open ? 'rotate-180' : ''}`} aria-hidden />
1323 {open
1324 ? t('issue.board.event.runCollapse')
1325 : t('issue.board.event.runExpand', { count: events.length, time: fmtDay(last.created_at) })}
1326 </button>
1327 ) : null}
1328 </div>
1329 </div>
1330 )
1331}
1332
1333/** Wrap each `value` inside the rendered sentence so changes stand out. */
1334function highlightValues(message: string, values: string[]): ReactNode {
1335 let parts: ReactNode[] = [message]
1336 values
1337 .filter((value) => value.length > 0)
1338 .forEach((value, index) => {
1339 const next: ReactNode[] = []
1340 for (const part of parts) {
1341 if (typeof part !== 'string') {
1342 next.push(part)
1343 continue
1344 }
1345 const at = part.indexOf(value)
1346 if (at === -1) {
1347 next.push(part)
1348 continue
1349 }
1350 next.push(
1351 part.slice(0, at),
1352 <span key={`${index}-${at}`} className="issue-event-value">
1353 {value}
1354 </span>,
1355 part.slice(at + value.length),
1356 )
1357 }
1358 parts = next
1359 })
1360 return parts
1361}
1362
1363/**
1364 * The verb plus its payload. The backend already stores what changed —
1365 * `content` for milestones (`old -> new`), labels and members, `old_title` /
1366 * `new_title` for renames — so the line can say what happened instead of
1367 * repeating "调整了里程碑" four times.
1368 */
1369function describeEvent(
1370 event: IssueEvent,
1371 memberName: (id: string) => string | undefined,
1372 t: ReturnType<typeof useT>['t'],
1373): ReactNode {
1374 const unset = t('issue.board.unset')
1375 const content = (event.content ?? '').trim()
1376 switch (event.event_type) {
1377 case 'milestone_updated': {
1378 const [from, to] = content.split('->').map((part) => part.trim())
1379 if (!from || !to) break
1380 const values = [from === 'none' ? unset : from, to === 'none' ? unset : to]
1381 return highlightValues(t('issue.event.verb.milestone_updated', { from: values[0], to: values[1] }), values)
1382 }
1383 case 'title_updated': {
1384 if (!event.new_title) break
1385 const from = event.old_title ?? unset
1386 return highlightValues(t('issue.event.verb.title_updated', { from, to: event.new_title }), [
1387 from,
1388 event.new_title,
1389 ])
1390 }
1391 case 'label_added':
1392 case 'label_removed': {
1393 if (!content) break
1394 const key = event.event_type === 'label_added' ? 'issue.event.verb.label_added' : 'issue.event.verb.label_removed'
1395 return highlightValues(t(key, { name: content }), [content])
1396 }
1397 case 'member_added':
1398 case 'member_removed': {
1399 // The payload is `Kind <uuid>`; a name we can resolve reads far better.
1400 const [kind, id] = content.split(' ')
1401 const name = id ? memberName(id) : undefined
1402 if (!kind || !name) break
1403 const key =
1404 event.event_type === 'member_added' ? 'issue.event.verb.member_added' : 'issue.event.verb.member_removed'
1405 return highlightValues(t(key, { name }), [name])
1406 }
1407 case 'pull_linked':
1408 case 'pull_unlinked': {
1409 // 后端存的是 `#<编号> <标题>`;整段作为高亮值即可。
1410 if (!content) break
1411 const key = event.event_type === 'pull_linked' ? 'issue.event.verb.pull_linked' : 'issue.event.verb.pull_unlinked'
1412 return highlightValues(t(key, { name: content }), [content])
1413 }
1414 default:
1415 break
1416 }
1417 return eventLabel(event.event_type)
1418}
Added view/app/src/domains/issues/ui/issue-form-modal.tsx view/app/src/domains/issues/ui/issue-form-modal.tsx +607 −0 View view/app/src/domains/issues/ui/issue-form-modal.tsx
@@ -0,0 +1,607 @@
1/**
2 * Create / edit dialog for one issue (`POST`/`PATCH …/issues`).
3 *
4 * The same form serves both flows: on edit it prefills from the issue and its
5 * bound labels, and PATCH uses the backend's three-state rules — an explicit
6 * `null` clears 摘要/里程碑/截止, a missing key leaves the field alone.
7 */
8
9import { Alert, Markdown } from '@app/components'
10import {
11 type BoardRef,
12 createIssue,
13 createIssueAttachment,
14 dateApiValue,
15 dateInputValue,
16 deleteIssueAttachment,
17 finalizeIssueAttachment,
18 type IssueDetail,
19 type IssueLabel,
20 type IssuePriority,
21 issueLabel,
22 listIssueLabels,
23 MAX_ATTACHMENT_BYTES,
24 type Milestone,
25 normalizeColor,
26 PRIORITIES,
27 putIssueUpload,
28 type UserDto,
29 updateIssue,
30} from '@app/domains/issues/model'
31import { formatDate, useT } from '@app/i18n'
32import { ApiFailure, formatBytes } from '@app/lib'
33import { Button } from '@app/ui'
34import {
35 CalendarDays,
36 Check,
37 ChevronRight,
38 Flag,
39 Loader2,
40 Milestone as MilestoneIcon,
41 Paperclip,
42 Tag,
43 Users,
44 X,
45} from 'lucide-react'
46import { type ReactNode, useEffect, useId, useRef, useState } from 'react'
47import type { BoardData } from '../model/use-board-data'
48import { Dialog } from './dialog'
49import { FilterMenu, UserAvatar } from './parts'
50
51export interface IssueFormModalProps {
52 open: boolean
53 boardRef: BoardRef
54 data: BoardData
55 /** Present when editing; `null`/`undefined` means "create". */
56 editing?: IssueDetail | null
57 /** Priority preset from a kanban column's "+" button. */
58 presetPriority?: IssuePriority
59 onClose: () => void
60 /** Called with the saved issue so the page can open it and refresh. */
61 onSaved: (issue: IssueDetail) => void
62}
63
64const TITLE_MAX = 255
65
66/** One file picked in the dialog: uploading → ready (linkable) | failed. */
67interface DraftAttachment {
68 id: string
69 filename: string
70 size: number
71 status: 'uploading' | 'ready' | 'failed'
72 error?: string
73}
74
75export function IssueFormModal({
76 open,
77 boardRef,
78 data,
79 editing,
80 presetPriority,
81 onClose,
82 onSaved,
83}: IssueFormModalProps) {
84 const { t } = useT()
85 const fieldId = useId()
86 const [title, setTitle] = useState('')
87 const [description, setDescription] = useState('')
88 const [content, setContent] = useState('')
89 const [priority, setPriority] = useState<IssuePriority>(presetPriority ?? 'none')
90 const [milestoneId, setMilestoneId] = useState('')
91 const [due, setDue] = useState('')
92 const [labelIds, setLabelIds] = useState<string[]>([])
93 const [assigneeIds, setAssigneeIds] = useState<number[]>([])
94 const [drafts, setDrafts] = useState<DraftAttachment[]>([])
95 const [preview, setPreview] = useState(false)
96 const [busy, setBusy] = useState(false)
97 const [error, setError] = useState<string | null>(null)
98 const [validation, setValidation] = useState<string | null>(null)
99 const fileInput = useRef<HTMLInputElement | null>(null)
100
101 // Reset the draft whenever the dialog opens for a different target, so a
102 // half-typed new issue never leaks into an edit (or vice versa).
103 useEffect(() => {
104 if (!open) return
105 setError(null)
106 setValidation(null)
107 setPriority(presetPriority ?? 'none')
108 if (editing) {
109 setTitle(editing.title)
110 setDescription(editing.description ?? '')
111 setContent(editing.content ?? '')
112 setMilestoneId(editing.milestone_id ?? '')
113 setDue(dateInputValue(editing.due_at))
114 setAssigneeIds(editing.assignees.map((user) => user.user_id))
115 setPriority(editing.priority)
116 let active = true
117 listIssueLabels(boardRef, editing.id)
118 .then((labels) => {
119 if (active) setLabelIds(labels.map((label) => label.id))
120 })
121 .catch(() => {
122 if (active) setLabelIds([])
123 })
124 return () => {
125 active = false
126 }
127 }
128 setTitle('')
129 setDescription('')
130 setContent('')
131 setMilestoneId('')
132 setDue('')
133 setLabelIds([])
134 setAssigneeIds([])
135 setDrafts([])
136 setPreview(false)
137 // eslint-disable-next-line react-hooks/exhaustive-deps
138 }, [open, editing?.id])
139
140 const labels = data.labels.data?.items ?? []
141 const milestones = (data.milestones.data?.items ?? []).filter((item) => !item.is_closed || item.id === milestoneId)
142 const members = (data.members.data?.items ?? []).filter((member) => member.user)
143 const boardName = data.board.data?.name ?? ''
144 const contentRef = useRef<HTMLTextAreaElement | null>(null)
145 const uploading = drafts.some((draft) => draft.status === 'uploading')
146
147 /**
148 * Pick files → reserve a row → PUT the bytes → finalize. A failure removes
149 * the half-created row again so the pending-upload cap stays honest.
150 */
151 async function attachFiles(files: FileList | null) {
152 if (!files || files.length === 0) return
153 for (const file of Array.from(files)) {
154 if (file.size <= 0 || file.size > MAX_ATTACHMENT_BYTES) {
155 setDrafts((prev) => [
156 ...prev,
157 {
158 id: `local-${crypto.randomUUID()}`,
159 filename: file.name,
160 size: file.size,
161 status: 'failed',
162 error: t('issue.board.form.attachmentTooLarge', { max: formatBytes(MAX_ATTACHMENT_BYTES) }),
163 },
164 ])
165 continue
166 }
167 const localId = `local-${crypto.randomUUID()}`
168 setDrafts((prev) => [...prev, { id: localId, filename: file.name, size: file.size, status: 'uploading' }])
169 let created: string | null = null
170 try {
171 const intent = await createIssueAttachment({
172 space_name: boardRef.space,
173 filename: file.name,
174 content_type: file.type || 'application/octet-stream',
175 size: file.size,
176 })
177 created = intent.id
178 setDrafts((prev) => prev.map((draft) => (draft.id === localId ? { ...draft, id: intent.id } : draft)))
179 await putIssueUpload(intent.upload_url, file)
180 const meta = await finalizeIssueAttachment(intent.id)
181 setDrafts((prev) =>
182 prev.map((draft) => (draft.id === intent.id ? { ...draft, id: meta.id, status: 'ready' } : draft)),
183 )
184 } catch (err) {
185 if (created) void deleteIssueAttachment(created).catch(() => undefined)
186 setDrafts((prev) =>
187 prev.map((draft) =>
188 draft.id === (created ?? localId)
189 ? { ...draft, status: 'failed', error: (err as Error)?.message ?? t('issue.board.form.uploadFailed') }
190 : draft,
191 ),
192 )
193 }
194 }
195 }
196
197 function removeDraft(id: string) {
198 setDrafts((prev) => prev.filter((draft) => draft.id !== id))
199 if (!id.startsWith('local-')) void deleteIssueAttachment(id).catch(() => undefined)
200 }
201
202 // The description grows with its content (Linear-style) instead of scrolling
203 // inside a fixed box.
204 useEffect(() => {
205 const node = contentRef.current
206 if (!node || !open) return
207 node.style.height = 'auto'
208 node.style.height = `${Math.min(node.scrollHeight, 300)}px`
209 }, [content, open])
210
211 async function submit() {
212 if (busy) return
213 const trimmed = title.trim()
214 if (!trimmed) {
215 setValidation(t('issue.board.form.validationTitle'))
216 return
217 }
218 // The backend counts Unicode code points (`chars().count()`), so do the
219 // same instead of JS UTF-16 units — an emoji title is not "too long".
220 if ([...trimmed].length > TITLE_MAX) {
221 setValidation(t('issue.board.form.validationTitleMax', { max: TITLE_MAX }))
222 return
223 }
224 setValidation(null)
225 setBusy(true)
226 setError(null)
227 try {
228 const payload = {
229 title: trimmed,
230 description: description.trim() || null,
231 content,
232 priority,
233 milestone_id: milestoneId || null,
234 due_at: dateApiValue(due),
235 label_ids: labelIds,
236 assignee_ids: assigneeIds,
237 attachment_ids: drafts.filter((draft) => draft.status === 'ready').map((draft) => draft.id),
238 }
239 const saved = editing ? await updateIssue(boardRef, editing.id, payload) : await createIssue(boardRef, payload)
240 onSaved(saved)
241 onClose()
242 } catch (err) {
243 setError(err instanceof ApiFailure ? err.message : t('issue.board.form.saveFailed'))
244 } finally {
245 setBusy(false)
246 }
247 }
248
249 return (
250 <Dialog
251 open={open}
252 onClose={() => {
253 if (!busy) onClose()
254 }}
255 title={
256 editing
257 ? t('issue.board.form.editTitle', { number: issueLabel(data.board.data, editing.number) })
258 : t('issue.board.new')
259 }
260 width="max-w-[720px]"
261 panelClassName="issue-form-dialog"
262 header={
263 <div className="issue-form-head">
264 {boardName ? <span className="issue-form-team">{boardName}</span> : null}
265 <ChevronRight className="h-3.5 w-3.5 shrink-0" aria-hidden />
266 <span className="issue-form-kicker">
267 {editing
268 ? t('issue.board.form.editTitle', { number: issueLabel(data.board.data, editing.number) })
269 : t('issue.board.new')}
270 </span>
271 </div>
272 }
273 footer={
274 <div className="issue-form-bar">
275 <div className="issue-form-chips">
276 <input
277 ref={fileInput}
278 type="file"
279 multiple
280 className="sr-only"
281 tabIndex={-1}
282 aria-hidden="true"
283 onChange={(event) => {
284 void attachFiles(event.target.files)
285 event.target.value = ''
286 }}
287 />
288 <button
289 type="button"
290 className="issue-form-attach"
291 aria-label={t('issue.board.form.attach')}
292 title={t('issue.board.form.attach')}
293 onClick={() => fileInput.current?.click()}
294 >
295 <Paperclip className="h-3.5 w-3.5" aria-hidden />
296 </button>
297 {drafts.map((draft) => (
298 <span
299 key={draft.id}
300 className={`issue-form-chip is-draft ${draft.status === 'failed' ? 'is-failed' : ''}`}
301 title={draft.error ?? draft.filename}
302 >
303 {draft.status === 'uploading' ? (
304 <Loader2 className="h-3.5 w-3.5 animate-spin text-shell-ink-muted" aria-hidden />
305 ) : (
306 <Paperclip className="h-3.5 w-3.5" aria-hidden />
307 )}
308 <span className="issue-chip-text">{draft.filename}</span>
309 <span className="issue-chip-size">{formatBytes(draft.size)}</span>
310 <button
311 type="button"
312 className="issue-chip-clear"
313 aria-label={t('issue.board.form.removeAttachment', { name: draft.filename })}
314 title={t('issue.board.form.removeAttachment', { name: draft.filename })}
315 onClick={() => removeDraft(draft.id)}
316 >
317 <X className="h-3 w-3" aria-hidden />
318 </button>
319 </span>
320 ))}
321 <FilterMenu
322 label={t('issue.board.filter.priority')}
323 leading={<Flag className="h-3.5 w-3.5" aria-hidden />}
324 value={priority}
325 options={PRIORITIES.map((meta) => ({ value: meta.id, label: meta.label, color: meta.color }))}
326 onChange={(value) => setPriority(value as IssuePriority)}
327 />
328 <FilterMenu
329 label={t('issue.board.filter.milestone')}
330 leading={<MilestoneIcon className="h-3.5 w-3.5" aria-hidden />}
331 value={milestoneId}
332 placeholder={t('issue.board.unset')}
333 active={milestoneId ? milestones.find((item) => item.id === milestoneId)?.name : undefined}
334 options={milestones.map((item) => ({ value: item.id, label: item.name }))}
335 onChange={setMilestoneId}
336 />
337 <DueChip value={due} onChange={setDue} />
338 <PickerChip
339 icon={<Tag className="h-3.5 w-3.5" aria-hidden />}
340 label={t('issue.board.labelsField')}
341 empty={t('issue.board.form.noLabels')}
342 options={labels.map((label) => ({ id: label.id, name: label.name, color: normalizeColor(label.color) }))}
343 selected={labelIds}
344 onToggle={(id) =>
345 setLabelIds((current) => (current.includes(id) ? current.filter((v) => v !== id) : [...current, id]))
346 }
347 />
348 <PickerChip
349 icon={<Users className="h-3.5 w-3.5" aria-hidden />}
350 label={t('issue.board.filter.assignee')}
351 empty={t('issue.board.form.noAssignees')}
352 align="right"
353 options={members.map((member) => {
354 const user = member.user as UserDto
355 return { id: user.user_id, name: user.display_name || user.username, user }
356 })}
357 selected={assigneeIds}
358 onToggle={(id) =>
359 setAssigneeIds((current) => (current.includes(id) ? current.filter((v) => v !== id) : [...current, id]))
360 }
361 />
362 </div>
363 <div className="issue-form-submit">
364 {busy ? <Loader2 className="h-4 w-4 animate-spin text-shell-ink-muted" aria-hidden /> : null}
365 <Button onClick={() => void submit()} disabled={busy || uploading}>
366 {busy ? t('common.state.busy') : editing ? t('common.action.save') : t('issue.board.new')}
367 </Button>
368 </div>
369 </div>
370 }
371 >
372 <div className="issue-form-body">
373 <div className="issue-form-title-row">
374 <input
375 id={`${fieldId}-title`}
376 aria-label={t('issue.board.form.title')}
377 aria-required="true"
378 aria-invalid={Boolean(validation)}
379 aria-describedby={validation ? `${fieldId}-validation` : undefined}
380 className="issue-form-title bg-transparent"
381 value={title}
382 onChange={(event) => setTitle(event.target.value)}
383 maxLength={TITLE_MAX + 20}
384 placeholder={t('issue.board.form.titlePlaceholder')}
385 autoFocus
386 />
387 {[...title].length > TITLE_MAX - 55 ? (
388 <span className="issue-form-count">
389 {[...title].length} / {TITLE_MAX}
390 </span>
391 ) : null}
392 </div>
393 <input
394 id={`${fieldId}-summary`}
395 aria-label={t('issue.board.form.summary')}
396 className="issue-form-summary bg-transparent"
397 value={description}
398 onChange={(event) => setDescription(event.target.value)}
399 placeholder={t('issue.board.form.summaryPlaceholder')}
400 />
401 <textarea
402 id={`${fieldId}-content`}
403 ref={contentRef}
404 aria-label={t('issue.board.form.content')}
405 value={content}
406 onChange={(event) => setContent(event.target.value)}
407 rows={3}
408 placeholder={t('issue.board.form.contentPlaceholder')}
409 className="issue-form-desc bg-transparent"
410 hidden={preview}
411 />
412 {preview ? <Markdown className="issue-form-preview">{content}</Markdown> : null}
413 {preview || content.trim().length > 0 ? (
414 <div className="issue-form-desc-tools">
415 <button
416 type="button"
417 className="issue-form-preview-toggle"
418 aria-pressed={preview}
419 onClick={() => setPreview((v) => !v)}
420 >
421 {preview ? t('issue.board.form.editSource') : t('issue.board.form.preview')}
422 </button>
423 </div>
424 ) : null}
425 {validation ? (
426 <div id={`${fieldId}-validation`}>
427 <Alert>{validation}</Alert>
428 </div>
429 ) : null}
430 {error ? <Alert>{error}</Alert> : null}
431 </div>
432 </Dialog>
433 )
434}
435
436/** 多选 chip:收起时是一枚胶囊,展开后在上方列出可勾选项。 */
437function PickerChip<Id extends string | number>({
438 icon,
439 label,
440 options,
441 selected,
442 onToggle,
443 empty,
444 align = 'left',
445}: {
446 icon: ReactNode
447 label: string
448 options: Array<{ id: Id; name: string; color?: string; user?: UserDto }>
449 selected: readonly Id[]
450 onToggle: (id: Id) => void
451 empty: string
452 align?: 'left' | 'right'
453}) {
454 const [open, setOpen] = useState(false)
455 const box = useRef<HTMLDivElement | null>(null)
456 const picked = options.filter((option) => selected.includes(option.id))
457
458 useEffect(() => {
459 if (!open) return
460 const onDown = (event: MouseEvent) => {
461 if (box.current && !box.current.contains(event.target as Node)) setOpen(false)
462 }
463 const onKey = (event: KeyboardEvent) => {
464 if (event.key === 'Escape') setOpen(false)
465 }
466 window.addEventListener('mousedown', onDown)
467 window.addEventListener('keydown', onKey)
468 return () => {
469 window.removeEventListener('mousedown', onDown)
470 window.removeEventListener('keydown', onKey)
471 }
472 }, [open])
473
474 if (options.length === 0) {
475 return (
476 <span className="issue-form-chip is-disabled" title={empty}>
477 {icon}
478 <span className="issue-chip-text is-empty">{label}</span>
479 </span>
480 )
481 }
482
483 return (
484 <div ref={box} className="relative">
485 <button
486 type="button"
487 className="issue-form-chip is-button"
488 aria-haspopup="listbox"
489 aria-expanded={open}
490 onClick={() => setOpen((v) => !v)}
491 >
492 {icon}
493 {picked.length === 0 ? (
494 <span className="issue-chip-text is-empty">{label}</span>
495 ) : (
496 <>
497 {picked
498 .slice(0, 3)
499 .map((option) =>
500 option.user ? (
501 <UserAvatar key={option.id} user={option.user} size={16} />
502 ) : option.color ? (
503 <span
504 key={option.id}
505 className="issue-chip-dot"
506 style={{ backgroundColor: option.color }}
507 aria-hidden
508 />
509 ) : null,
510 )}
511 <span className="issue-chip-text">
512 {picked.length === 1 ? picked[0].name : `${label} · ${picked.length}`}
513 </span>
514 </>
515 )}
516 </button>
517 {open ? (
518 <div
519 className={`board-filter-popover issue-form-popover ${align === 'right' ? 'is-right' : ''}`}
520 role="listbox"
521 aria-multiselectable="true"
522 aria-label={label}
523 >
524 {options.map((option) => {
525 const on = selected.includes(option.id)
526 return (
527 <button
528 key={option.id}
529 type="button"
530 role="option"
531 aria-selected={on}
532 onClick={() => onToggle(option.id)}
533 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"
534 >
535 <span className={`issue-check ${on ? 'is-on' : ''}`} aria-hidden>
536 {on ? <Check className="h-3 w-3" /> : null}
537 </span>
538 {option.user ? (
539 <UserAvatar user={option.user} size={18} />
540 ) : option.color ? (
541 <span
542 className="h-2.5 w-2.5 shrink-0 rounded-full"
543 style={{ backgroundColor: option.color }}
544 aria-hidden
545 />
546 ) : null}
547 <span className={`min-w-0 flex-1 truncate ${on ? 'font-medium' : ''}`}>{option.name}</span>
548 </button>
549 )
550 })}
551 </div>
552 ) : null}
553 </div>
554 )
555}
556
557/** 截止日期 chip:显示格式化日期,点击调起原生日期选择器。 */
558function DueChip({ value, onChange }: { value: string; onChange: (next: string) => void }) {
559 const { t } = useT()
560 const input = useRef<HTMLInputElement | null>(null)
561 return (
562 <span className="issue-form-chip is-button">
563 <button
564 type="button"
565 className="issue-form-chip-main"
566 onClick={() => {
567 const node = input.current
568 if (!node) return
569 try {
570 ;(node as HTMLInputElement & { showPicker?: () => void }).showPicker?.()
571 } catch {
572 // 浏览器拒绝 showPicker 时退化为聚焦,用户仍可手动输入日期。
573 }
574 node.focus()
575 }}
576 >
577 <CalendarDays className="h-3.5 w-3.5" aria-hidden />
578 <span className={`issue-chip-text ${value ? '' : 'is-empty'}`}>
579 {value ? formatDate(value) : t('issue.board.filter.due')}
580 </span>
581 </button>
582 {value ? (
583 <button
584 type="button"
585 className="issue-chip-clear"
586 aria-label={t('issue.board.clearDate')}
587 title={t('issue.board.clearDate')}
588 onClick={() => onChange('')}
589 >
590 <X className="h-3 w-3" aria-hidden />
591 </button>
592 ) : null}
593 <input
594 ref={input}
595 type="date"
596 tabIndex={-1}
597 aria-hidden="true"
598 className="issue-chip-date"
599 value={value}
600 onChange={(event) => onChange(event.target.value)}
601 />
602 </span>
603 )
604}
605
606/** Types kept explicit so the modal cannot drift from the API payloads. */
607export type { IssueLabel, Milestone }
Added view/app/src/domains/issues/ui/parts.tsx view/app/src/domains/issues/ui/parts.tsx +399 −0 View view/app/src/domains/issues/ui/parts.tsx
@@ -0,0 +1,399 @@
1/**
2 * Small shared render pieces for the 问题集 pages: badges, avatars, filter
3 * menus. They own no data — every value comes from `@/entities/issue` metadata
4 * so the kanban, the table and the detail panel render the same state the same
5 * way.
6 */
7
8import type { IssueLabel, IssuePriority, IssueState, UserDto } from '@app/domains/issues/model'
9import { describeDue, normalizeColor, priorityOf, readableText, STATES } from '@app/domains/issues/model'
10import { useT } from '@app/i18n'
11import { Check, ChevronDown } from 'lucide-react'
12import { type ReactNode, useEffect, useId, useRef, useState } from 'react'
13
14export function PriorityBadge({ priority, compact = false }: { priority: IssuePriority; compact?: boolean }) {
15 const { t } = useT()
16 const meta = priorityOf(priority)
17 return (
18 <span
19 data-priority={meta.id}
20 className={`issue-priority-badge inline-flex shrink-0 items-center rounded-full font-semibold ${compact ? 'px-1.5 py-px text-[10.5px]' : 'px-2 py-0.5 text-[11px]'}`}
21 title={t('issue.filter.chip.priority', { value: meta.label })}
22 >
23 {meta.label}
24 </span>
25 )
26}
27
28export function StateBadge({ state }: { state: IssueState }) {
29 const meta = STATES[state] ?? STATES.open
30 return (
31 <span
32 data-state={state}
33 className="issue-state-badge inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold"
34 >
35 <span className="h-1.5 w-1.5 rounded-full bg-current" aria-hidden />
36 {meta.label}
37 </span>
38 )
39}
40
41/**
42 * Linear-style priority mark: three bars filled up to the priority's rank, an
43 * outline-only mark for 无优先级. Urgent keeps a red accent; everything else
44 * inherits the row's ink so a page of issues does not turn into a colour chart.
45 */
46export function PriorityGlyph({ priority }: { priority: IssuePriority }) {
47 const meta = priorityOf(priority)
48 const filled = meta.rank <= 0 ? 0 : Math.min(meta.rank, 3)
49 return (
50 <span className="issue-priority-glyph" data-priority={meta.id} title={meta.label}>
51 <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden focusable="false">
52 {[0, 1, 2].map((index) => (
53 <rect
54 key={index}
55 className={index < filled ? 'is-on' : undefined}
56 x={2.5 + index * 4.25}
57 y={9.5 - index * 3.25}
58 width="3"
59 height={4.5 + index * 3.25}
60 rx="1"
61 />
62 ))}
63 </svg>
64 <span className="sr-only">{meta.label}</span>
65 </span>
66 )
67}
68
69export function LabelChip({ label, onRemove }: { label: IssueLabel; onRemove?: () => void }) {
70 const { t } = useT()
71 const color = normalizeColor(label.color)
72 return (
73 <span
74 className="inline-flex max-w-[150px] shrink-0 items-center gap-1 rounded-full px-1.5 py-px text-[10.5px] font-semibold"
75 style={{ backgroundColor: color, color: readableText(color) }}
76 title={label.description ? `${label.name} · ${label.description}` : label.name}
77 >
78 <span className="truncate">{label.name}</span>
79 {onRemove ? (
80 <button
81 type="button"
82 aria-label={t('issue.board.removeLabel', { name: label.name })}
83 onClick={(event) => {
84 event.stopPropagation()
85 onRemove()
86 }}
87 className="shrink-0 opacity-70 hover:opacity-100"
88 >
89 ×
90 </button>
91 ) : null}
92 </span>
93 )
94}
95
96export function DueChip({ due, state }: { due: string | null | undefined; state: IssueState }) {
97 const meta = describeDue(due, state)
98 if (!meta) return null
99 const tone = meta.tone === 'over' ? 'text-[#e5484d]' : meta.tone === 'due' ? 'text-[#b26a00]' : 'text-shell-ink-muted'
100 return <span className={`shrink-0 text-[11px] ${tone}`}>{meta.text}</span>
101}
102
103/** Up to `max` assignee avatars, then a "+n" chip. */
104export function AvatarStack({ users, max = 3, size = 18 }: { users: UserDto[]; max?: number; size?: number }) {
105 if (users.length === 0) return null
106 const shown = users.slice(0, max)
107 const rest = users.length - shown.length
108 return (
109 <span className="flex shrink-0 items-center -space-x-1">
110 {shown.map((user) => (
111 <UserAvatar key={user.user_id} user={user} size={size} />
112 ))}
113 {rest > 0 ? (
114 <span
115 className="grid place-content-center rounded-full bg-shell-tile text-[10px] font-semibold text-shell-ink ring-2 ring-shell-surface"
116 style={{ width: size, height: size }}
117 title={users.map((u) => u.display_name || u.username).join(', ')}
118 >
119 +{rest}
120 </span>
121 ) : null}
122 </span>
123 )
124}
125
126export function UserAvatar({ user, size = 18 }: { user: UserDto; size?: number }) {
127 const initial = (user.display_name || user.username || '?').slice(0, 1).toUpperCase()
128 if (user.avatar_url) {
129 return (
130 <img
131 src={user.avatar_url}
132 alt={user.display_name || user.username}
133 title={user.display_name || user.username}
134 className="shrink-0 rounded-full object-cover ring-2 ring-shell-surface"
135 style={{ width: size, height: size }}
136 loading="lazy"
137 />
138 )
139 }
140 return (
141 <span
142 className="grid shrink-0 place-content-center rounded-full bg-shell-tile text-[10px] font-semibold text-shell-ink ring-2 ring-shell-surface"
143 style={{ width: size, height: size }}
144 title={user.display_name || user.username}
145 >
146 {initial}
147 </span>
148 )
149}
150
151export interface MenuOption {
152 value: string
153 label: string
154 /** Swatch shown on the left (label colour, priority colour, …). */
155 color?: string
156 hint?: string
157}
158
159/**
160 * Compact popover used by the toolbar filters and the detail sidebar pickers.
161 * Closes on outside click and Escape; the trigger shows the active choice so a
162 * filtered board is readable at a glance.
163 */
164export function FilterMenu({
165 label,
166 value,
167 options,
168 onChange,
169 active,
170 placeholder,
171 align = 'left',
172 bare = false,
173 iconOnly = false,
174 leading,
175 extra,
176}: {
177 label: string
178 value: string
179 options: MenuOption[]
180 onChange: (value: string) => void
181 /** Text shown on the trigger; defaults to the matching option's label. */
182 active?: string
183 placeholder?: string
184 align?: 'left' | 'right'
185 /** Bordered chip for toolbars vs. a full-width hover row for the detail rail. */
186 bare?: boolean
187 /** Ghost icon-only trigger (list rows): no value text, colour dot or chevron. */
188 iconOnly?: boolean
189 /** Icon + label pinned to the start of the trigger (rail rows). */
190 leading?: ReactNode
191 /** Free-form block under the option list (an inline "new label" row, …). */
192 extra?: ReactNode
193}) {
194 const { t } = useT()
195 const [open, setOpen] = useState(false)
196 const box = useRef<HTMLDivElement | null>(null)
197 const trigger = useRef<HTMLButtonElement | null>(null)
198 const menuId = useId()
199 const current = options.find((option) => option.value === value)
200
201 useEffect(() => {
202 if (!open) return
203 const onDown = (event: MouseEvent) => {
204 if (box.current && !box.current.contains(event.target as Node)) setOpen(false)
205 }
206 const frame = requestAnimationFrame(() => {
207 const option =
208 box.current?.querySelector<HTMLButtonElement>('[role=option][aria-selected=true]') ??
209 box.current?.querySelector<HTMLButtonElement>('[role=option]')
210 option?.focus()
211 })
212 const onKey = (event: KeyboardEvent) => {
213 if (event.key === 'Escape') {
214 setOpen(false)
215 trigger.current?.focus()
216 }
217 }
218 window.addEventListener('mousedown', onDown)
219 window.addEventListener('keydown', onKey)
220 return () => {
221 cancelAnimationFrame(frame)
222 window.removeEventListener('mousedown', onDown)
223 window.removeEventListener('keydown', onKey)
224 }
225 }, [open])
226
227 return (
228 <div
229 ref={box}
230 className="board-filter-menu relative shrink-0"
231 onBlur={(event) => {
232 if (!event.currentTarget.contains(event.relatedTarget as Node)) setOpen(false)
233 }}
234 >
235 <button
236 type="button"
237 ref={trigger}
238 aria-label={t('issue.filter.triggerAria', {
239 label,
240 value: active ?? current?.label ?? placeholder ?? t('issue.board.unset'),
241 })}
242 aria-expanded={open}
243 aria-controls={open ? menuId : undefined}
244 aria-haspopup="listbox"
245 onKeyDown={(event) => {
246 if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
247 event.preventDefault()
248 setOpen(true)
249 }
250 }}
251 onClick={() => setOpen((v) => !v)}
252 className={`board-filter-trigger flex h-7 items-center gap-1 rounded-md px-2 text-[12.5px] transition-colors ${
253 iconOnly ? 'board-filter-trigger-icon' : bare ? 'board-filter-trigger-bare' : ''
254 } ${
255 iconOnly
256 ? ''
257 : value
258 ? 'bg-shell-active text-shell-ink'
259 : 'bg-shell-field text-shell-ink-muted hover:text-shell-ink'
260 }`}
261 >
262 {leading ? <span className="board-filter-leading flex shrink-0 items-center gap-2">{leading}</span> : null}
263 {!iconOnly && current?.color ? (
264 <span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: current.color }} aria-hidden />
265 ) : null}
266 {iconOnly ? null : (
267 <span className="board-filter-trigger-value max-w-[120px] truncate">
268 {active ?? current?.label ?? placeholder ?? label}
269 </span>
270 )}
271 {iconOnly ? null : <ChevronDown className="board-filter-chevron h-3 w-3 shrink-0 opacity-70" aria-hidden />}
272 </button>
273 {open ? (
274 <div
275 id={menuId}
276 onKeyDown={(event) => {
277 if (event.key === 'Escape') {
278 event.preventDefault()
279 event.stopPropagation()
280 setOpen(false)
281 trigger.current?.focus()
282 return
283 }
284 const keys = ['ArrowDown', 'ArrowUp', 'Home', 'End']
285 if (!keys.includes(event.key)) return
286 event.preventDefault()
287 const options = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('[role=option]'))
288 const currentIndex = options.indexOf(document.activeElement as HTMLButtonElement)
289 const next =
290 event.key === 'Home'
291 ? 0
292 : event.key === 'End'
293 ? options.length - 1
294 : (currentIndex + (event.key === 'ArrowDown' ? 1 : -1) + options.length) % options.length
295 options[next]?.focus()
296 }}
297 className={`board-filter-popover absolute z-30 mt-1 max-h-[320px] w-[220px] overflow-y-auto rounded-lg border-2 border-shell-hairline bg-shell-surface p-1 shadow-[0_6px_20px_rgb(28_30_34/14%)] ${
298 align === 'right' ? 'right-0' : 'left-0'
299 }`}
300 role="listbox"
301 aria-label={label}
302 >
303 {placeholder ? (
304 <MenuRow
305 option={{ value: '', label: placeholder }}
306 selected={!value}
307 onPick={(next) => {
308 onChange(next)
309 setOpen(false)
310 trigger.current?.focus()
311 }}
312 />
313 ) : null}
314 {options.map((option) => (
315 <MenuRow
316 key={option.value}
317 option={option}
318 selected={option.value === value}
319 onPick={(next) => {
320 onChange(next)
321 setOpen(false)
322 trigger.current?.focus()
323 }}
324 />
325 ))}
326 {options.length === 0 ? (
327 <p className="px-2 py-3 text-center text-[12px] text-shell-ink-muted">{t('issue.board.noOptions')}</p>
328 ) : null}
329 {extra ? <div className="mt-1 border-t-2 border-shell-hairline p-2">{extra}</div> : null}
330 </div>
331 ) : null}
332 </div>
333 )
334}
335
336function MenuRow({
337 option,
338 selected,
339 onPick,
340}: {
341 option: MenuOption
342 selected: boolean
343 onPick: (value: string) => void
344}) {
345 return (
346 <button
347 type="button"
348 role="option"
349 aria-selected={selected}
350 onClick={() => onPick(option.value)}
351 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 ${
352 selected ? 'font-semibold text-shell-ink' : 'text-shell-ink-muted'
353 }`}
354 >
355 {option.color ? (
356 <span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: option.color }} aria-hidden />
357 ) : (
358 <span className="w-2.5 shrink-0" aria-hidden />
359 )}
360 <span className="min-w-0 flex-1 truncate">{option.label}</span>
361 {option.hint ? <span className="shrink-0 text-[11px] text-shell-ink-muted">{option.hint}</span> : null}
362 {selected ? <Check className="h-3.5 w-3.5 shrink-0 text-shell-ink" aria-hidden /> : null}
363 </button>
364 )
365}
366
367/** Inline confirm step used by destructive rows (delete label / milestone / issue). */
368export function DangerButton({
369 label,
370 busy,
371 onConfirm,
372 className = '',
373}: {
374 label: string
375 busy?: boolean
376 onConfirm: () => void
377 className?: string
378}) {
379 const { t } = useT()
380 const [armed, setArmed] = useState(false)
381 useEffect(() => {
382 if (!armed) return
383 const timer = setTimeout(() => setArmed(false), 4000)
384 return () => clearTimeout(timer)
385 }, [armed])
386 return (
387 <button
388 type="button"
389 disabled={busy}
390 data-armed={armed ? 'true' : undefined}
391 onClick={() => (armed ? onConfirm() : setArmed(true))}
392 className={`shrink-0 rounded px-2 py-1 text-[12px] transition-colors disabled:opacity-50 ${
393 armed ? 'bg-[#e5484d] font-semibold text-white' : 'text-[#b42318] hover:bg-[#e5484d]/10'
394 } ${className}`}
395 >
396 {busy ? t('common.action.working') : armed ? t('issue.board.confirmDiscard') : label}
397 </button>
398 )
399}
Added view/app/src/domains/issues/index.ts view/app/src/domains/issues/index.ts +11 −0 View view/app/src/domains/issues/index.ts
@@ -0,0 +1,11 @@
1// Public API of features/issue-board — the 问题集 (issue tracker) pages:
2// toolbar filters, kanban/list body, issue detail, and the create/edit/manage
3// dialogs. Consumers import from '@app/domains/issues', never deeper.
4
5export { prefetchBoardView } from './model/prefetch'
6export { type BoardData, ISSUE_PAGE_SIZE, useBoardData } from './model/use-board-data'
7export { BoardActions, BoardFilters, BoardViewTabs } from './ui/board-filters'
8export { BoardManageModal } from './ui/board-manage-modal'
9export { BoardSkeleton, IssueBoardBody } from './ui/issue-board'
10export { IssueDetail } from './ui/issue-detail'
11export { IssueFormModal } from './ui/issue-form-modal'