@monkey-mini-app/ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/components.json +23 -0
  2. package/dist/globals.css +2 -0
  3. package/dist/index.js +138 -0
  4. package/dist/manifest.json +11 -0
  5. package/dist/src/blocks/activity-feed.tsx +9 -0
  6. package/dist/src/blocks/app-shell.tsx +37 -0
  7. package/dist/src/blocks/attachment-gallery.tsx +30 -0
  8. package/dist/src/blocks/bar-chart.tsx +33 -0
  9. package/dist/src/blocks/comment-thread.tsx +30 -0
  10. package/dist/src/blocks/description-list.tsx +16 -0
  11. package/dist/src/blocks/detail-panel.tsx +43 -0
  12. package/dist/src/blocks/donut-chart.tsx +38 -0
  13. package/dist/src/blocks/env-badge.tsx +23 -0
  14. package/dist/src/blocks/file-tree.tsx +5 -0
  15. package/dist/src/blocks/filter-bar.tsx +21 -0
  16. package/dist/src/blocks/gauge.tsx +13 -0
  17. package/dist/src/blocks/notification-center.tsx +22 -0
  18. package/dist/src/blocks/page-header.tsx +30 -0
  19. package/dist/src/blocks/progress-ring.tsx +25 -0
  20. package/dist/src/blocks/radar-chart.tsx +31 -0
  21. package/dist/src/blocks/request-inspector.tsx +34 -0
  22. package/dist/src/blocks/run-timeline.tsx +5 -0
  23. package/dist/src/blocks/scrollspy.tsx +42 -0
  24. package/dist/src/blocks/severity-chip.tsx +27 -0
  25. package/dist/src/blocks/sparkline.tsx +19 -0
  26. package/dist/src/blocks/stat-card.tsx +49 -0
  27. package/dist/src/blocks/status-badge.tsx +39 -0
  28. package/dist/src/blocks/terminal.tsx +10 -0
  29. package/dist/src/blocks/test-step-list.tsx +19 -0
  30. package/dist/src/blocks/trend-card.tsx +51 -0
  31. package/dist/src/components/accordion.tsx +72 -0
  32. package/dist/src/components/alert-dialog.tsx +185 -0
  33. package/dist/src/components/alert.tsx +76 -0
  34. package/dist/src/components/aspect-ratio.tsx +22 -0
  35. package/dist/src/components/attachment.tsx +207 -0
  36. package/dist/src/components/avatar.tsx +107 -0
  37. package/dist/src/components/badge.tsx +52 -0
  38. package/dist/src/components/breadcrumb.tsx +125 -0
  39. package/dist/src/components/bubble.tsx +128 -0
  40. package/dist/src/components/button-group.tsx +87 -0
  41. package/dist/src/components/button.tsx +58 -0
  42. package/dist/src/components/calendar.tsx +221 -0
  43. package/dist/src/components/card.tsx +103 -0
  44. package/dist/src/components/chart.tsx +371 -0
  45. package/dist/src/components/checkbox.tsx +29 -0
  46. package/dist/src/components/collapsible.tsx +19 -0
  47. package/dist/src/components/combobox.tsx +295 -0
  48. package/dist/src/components/command.tsx +196 -0
  49. package/dist/src/components/context-menu.tsx +271 -0
  50. package/dist/src/components/dialog.tsx +158 -0
  51. package/dist/src/components/drawer.tsx +226 -0
  52. package/dist/src/components/dropdown-menu.tsx +268 -0
  53. package/dist/src/components/empty.tsx +123 -0
  54. package/dist/src/components/field.tsx +236 -0
  55. package/dist/src/components/hover-card.tsx +49 -0
  56. package/dist/src/components/input-group.tsx +158 -0
  57. package/dist/src/components/input.tsx +20 -0
  58. package/dist/src/components/item.tsx +201 -0
  59. package/dist/src/components/kbd.tsx +26 -0
  60. package/dist/src/components/label.tsx +20 -0
  61. package/dist/src/components/marker.tsx +71 -0
  62. package/dist/src/components/menubar.tsx +280 -0
  63. package/dist/src/components/message-scroller.tsx +129 -0
  64. package/dist/src/components/message.tsx +92 -0
  65. package/dist/src/components/native-select.tsx +61 -0
  66. package/dist/src/components/navigation-menu.tsx +168 -0
  67. package/dist/src/components/pagination.tsx +135 -0
  68. package/dist/src/components/popover.tsx +88 -0
  69. package/dist/src/components/progress.tsx +83 -0
  70. package/dist/src/components/questionnaire.tsx +329 -0
  71. package/dist/src/components/radio-group.tsx +36 -0
  72. package/dist/src/components/resizable.tsx +50 -0
  73. package/dist/src/components/scroll-area.tsx +52 -0
  74. package/dist/src/components/select.tsx +201 -0
  75. package/dist/src/components/separator.tsx +23 -0
  76. package/dist/src/components/sheet.tsx +136 -0
  77. package/dist/src/components/sidebar.tsx +721 -0
  78. package/dist/src/components/skeleton.tsx +13 -0
  79. package/dist/src/components/slider.tsx +52 -0
  80. package/dist/src/components/spinner.tsx +10 -0
  81. package/dist/src/components/switch.tsx +32 -0
  82. package/dist/src/components/table.tsx +114 -0
  83. package/dist/src/components/tabs.tsx +82 -0
  84. package/dist/src/components/textarea.tsx +18 -0
  85. package/dist/src/components/toast.tsx +234 -0
  86. package/dist/src/components/toggle-group.tsx +89 -0
  87. package/dist/src/components/toggle.tsx +43 -0
  88. package/dist/src/components/tooltip.tsx +66 -0
  89. package/dist/src/composites/autocomplete.tsx +55 -0
  90. package/dist/src/composites/cascader.tsx +46 -0
  91. package/dist/src/composites/color-picker.tsx +19 -0
  92. package/dist/src/composites/confirm-dialog.tsx +56 -0
  93. package/dist/src/composites/copyable.tsx +37 -0
  94. package/dist/src/composites/currency-input.tsx +31 -0
  95. package/dist/src/composites/date-chrome.tsx +157 -0
  96. package/dist/src/composites/date-picker.tsx +91 -0
  97. package/dist/src/composites/date-range-picker.tsx +96 -0
  98. package/dist/src/composites/date-time-picker.tsx +115 -0
  99. package/dist/src/composites/date-time-range-picker.tsx +51 -0
  100. package/dist/src/composites/duration-input.tsx +122 -0
  101. package/dist/src/composites/mini-calendar.tsx +24 -0
  102. package/dist/src/composites/number-field.tsx +53 -0
  103. package/dist/src/composites/password-field.tsx +44 -0
  104. package/dist/src/composites/phone-input.tsx +22 -0
  105. package/dist/src/composites/rating.tsx +39 -0
  106. package/dist/src/composites/relative-date-picker.tsx +62 -0
  107. package/dist/src/composites/search-input.tsx +49 -0
  108. package/dist/src/composites/slider-range.tsx +30 -0
  109. package/dist/src/composites/tag-input.tsx +56 -0
  110. package/dist/src/composites/time-picker.tsx +199 -0
  111. package/dist/src/composites/time-range-picker.tsx +25 -0
  112. package/dist/src/composites/timezone-select.tsx +251 -0
  113. package/dist/src/composites/transfer.tsx +52 -0
  114. package/dist/src/composites/user-picker.tsx +40 -0
  115. package/dist/src/hooks/use-mobile.ts +19 -0
  116. package/dist/src/i18n/context.tsx +72 -0
  117. package/dist/src/i18n/en.ts +168 -0
  118. package/dist/src/i18n/zh.ts +160 -0
  119. package/dist/src/index.ts +134 -0
  120. package/dist/src/lib/icons.tsx +16 -0
  121. package/dist/src/lib/illustrations.tsx +573 -0
  122. package/dist/src/lib/utils.ts +6 -0
  123. package/dist/src/products/cells.tsx +18 -0
  124. package/dist/src/products/code-block.tsx +75 -0
  125. package/dist/src/products/code-editor.tsx +49 -0
  126. package/dist/src/products/column-meta.d.ts +9 -0
  127. package/dist/src/products/data-grid.tsx +689 -0
  128. package/dist/src/products/diff-viewer.tsx +129 -0
  129. package/dist/src/products/event-calendar.tsx +845 -0
  130. package/dist/src/products/export-csv.ts +25 -0
  131. package/dist/src/products/file-dropzone.tsx +41 -0
  132. package/dist/src/products/gantt.tsx +41 -0
  133. package/dist/src/products/jira-wiki-parse.ts +197 -0
  134. package/dist/src/products/jira-wiki.tsx +285 -0
  135. package/dist/src/products/jql-input.tsx +62 -0
  136. package/dist/src/products/jql-language.ts +70 -0
  137. package/dist/src/products/json-viewer.tsx +37 -0
  138. package/dist/src/products/kanban-issue-panel.tsx +98 -0
  139. package/dist/src/products/kanban.tsx +245 -0
  140. package/dist/src/products/log-viewer.tsx +33 -0
  141. package/dist/src/products/markdown-editor.tsx +63 -0
  142. package/dist/src/products/markdown.tsx +26 -0
  143. package/dist/src/products/rich-text-editor.tsx +84 -0
  144. package/dist/src/products/sortable-list.tsx +61 -0
  145. package/dist/src/products/stepper.tsx +89 -0
  146. package/dist/src/products/timeline.tsx +32 -0
  147. package/dist/src/products/tree-view.tsx +56 -0
  148. package/dist/src/styles/globals.css +352 -0
  149. package/package.json +100 -0
@@ -0,0 +1,25 @@
1
+ export function exportCsv<T extends Record<string, unknown>>(
2
+ rows: T[],
3
+ filename = "export.csv"
4
+ ) {
5
+ if (rows.length === 0) return
6
+ const keys = Object.keys(rows[0])
7
+ const csv = [
8
+ keys.join(","),
9
+ ...rows.map((row) =>
10
+ keys
11
+ .map((key) => {
12
+ const raw = String(row[key] ?? "")
13
+ return `"${raw.replaceAll('"', '""')}"`
14
+ })
15
+ .join(",")
16
+ ),
17
+ ].join("\n")
18
+ const blob = new Blob([csv], { type: "text/csv;charset=utf-8" })
19
+ const url = URL.createObjectURL(blob)
20
+ const link = document.createElement("a")
21
+ link.href = url
22
+ link.download = filename
23
+ link.click()
24
+ URL.revokeObjectURL(url)
25
+ }
@@ -0,0 +1,41 @@
1
+ "use client"
2
+
3
+ import { useDropzone } from "react-dropzone"
4
+
5
+ import { cn } from "@monkey-mini-app/ui/lib/utils"
6
+ import { useLabels } from "@monkey-mini-app/ui/i18n/context"
7
+ import { Attachment, AttachmentContent, AttachmentTitle } from "@monkey-mini-app/ui/components/attachment"
8
+
9
+ export function FileDropzone({
10
+ files,
11
+ onFiles,
12
+ }: {
13
+ files?: File[]
14
+ onFiles?: (files: File[]) => void
15
+ }) {
16
+ const t = useLabels("fileDropzone")
17
+ const { getRootProps, getInputProps, isDragActive } = useDropzone({
18
+ onDrop: (accepted) => onFiles?.(accepted),
19
+ })
20
+ return (
21
+ <div className="flex flex-col gap-2" data-testid="file-dropzone">
22
+ <div
23
+ {...getRootProps()}
24
+ className={cn(
25
+ "flex cursor-pointer flex-col items-center justify-center rounded-xl border border-dashed px-4 py-8 text-sm text-muted-foreground",
26
+ isDragActive && "border-primary bg-muted/40"
27
+ )}
28
+ >
29
+ <input {...getInputProps()} />
30
+ {t.drop}
31
+ </div>
32
+ {files?.map((file) => (
33
+ <Attachment key={file.name}>
34
+ <AttachmentContent>
35
+ <AttachmentTitle>{file.name}</AttachmentTitle>
36
+ </AttachmentContent>
37
+ </Attachment>
38
+ ))}
39
+ </div>
40
+ )
41
+ }
@@ -0,0 +1,41 @@
1
+ "use client"
2
+
3
+ import { differenceInCalendarDays, min, max } from "date-fns"
4
+
5
+ export type GanttTask = {
6
+ id: string
7
+ title: string
8
+ start: Date
9
+ end: Date
10
+ }
11
+
12
+ export function Gantt({ tasks }: { tasks: GanttTask[] }) {
13
+ if (tasks.length === 0) {
14
+ return <div data-testid="gantt" className="text-muted-foreground text-sm">No tasks</div>
15
+ }
16
+ const start = min(tasks.map((task) => task.start))
17
+ const end = max(tasks.map((task) => task.end))
18
+ const span = Math.max(differenceInCalendarDays(end, start), 1)
19
+ return (
20
+ <div className="flex flex-col gap-2" data-testid="gantt">
21
+ {tasks.map((task) => {
22
+ const offset = differenceInCalendarDays(task.start, start)
23
+ const width = Math.max(differenceInCalendarDays(task.end, task.start), 1)
24
+ return (
25
+ <div key={task.id} className="grid grid-cols-[8rem_1fr] items-center gap-3">
26
+ <div className="truncate text-sm">{task.title}</div>
27
+ <div className="relative h-7 rounded-md bg-muted">
28
+ <div
29
+ className="absolute top-1 bottom-1 rounded-sm bg-primary/70"
30
+ style={{
31
+ left: `${(offset / span) * 100}%`,
32
+ width: `${(width / span) * 100}%`,
33
+ }}
34
+ />
35
+ </div>
36
+ </div>
37
+ )
38
+ })}
39
+ </div>
40
+ )
41
+ }
@@ -0,0 +1,197 @@
1
+ export type WikiInline =
2
+ | { type: "text"; value: string }
3
+ | { type: "bold"; children: WikiInline[] }
4
+ | { type: "italic"; children: WikiInline[] }
5
+ | { type: "strike"; children: WikiInline[] }
6
+ | { type: "code"; value: string }
7
+ | { type: "link"; href: string; label: string }
8
+ | { type: "attachment"; name: string }
9
+
10
+ export type WikiBlock =
11
+ | { type: "heading"; level: number; children: WikiInline[] }
12
+ | { type: "paragraph"; children: WikiInline[] }
13
+ | { type: "list"; ordered: boolean; items: WikiInline[][] }
14
+ | { type: "code"; language?: string; value: string }
15
+ | { type: "quote"; children: WikiInline[] }
16
+ | { type: "panel"; title?: string; children: WikiBlock[] }
17
+ | { type: "table"; header: WikiInline[][]; rows: WikiInline[][][] }
18
+ | { type: "hr" }
19
+
20
+ export function parseInline(input: string): WikiInline[] {
21
+ const nodes: WikiInline[] = []
22
+ let rest = input
23
+ while (rest.length) {
24
+ const attachment = /^\[\^([^\]]+)\]/.exec(rest)
25
+ if (attachment) {
26
+ nodes.push({ type: "attachment", name: attachment[1] ?? "" })
27
+ rest = rest.slice(attachment[0].length)
28
+ continue
29
+ }
30
+ const link = /^\[([^\]|]+)\|([^\]]+)\]/.exec(rest) ?? /^\[([^\]]+)\]/.exec(rest)
31
+ if (link) {
32
+ const label = link[1] ?? ""
33
+ const href = link[2] ?? link[1] ?? ""
34
+ nodes.push({ type: "link", href, label })
35
+ rest = rest.slice(link[0].length)
36
+ continue
37
+ }
38
+ const code = /^\{\{(.+?)\}\}/.exec(rest)
39
+ if (code) {
40
+ nodes.push({ type: "code", value: code[1] ?? "" })
41
+ rest = rest.slice(code[0].length)
42
+ continue
43
+ }
44
+ const bold = /^\*([^*\n]+)\*/.exec(rest)
45
+ if (bold) {
46
+ nodes.push({ type: "bold", children: parseInline(bold[1] ?? "") })
47
+ rest = rest.slice(bold[0].length)
48
+ continue
49
+ }
50
+ const italic = /^_([^_\n]+)_/.exec(rest)
51
+ if (italic) {
52
+ nodes.push({ type: "italic", children: parseInline(italic[1] ?? "") })
53
+ rest = rest.slice(italic[0].length)
54
+ continue
55
+ }
56
+ const strike = /^-([^-]+)-(?=$|[\s.,;:!?)"]])/.exec(rest)
57
+ if (strike) {
58
+ nodes.push({ type: "strike", children: parseInline(strike[1] ?? "") })
59
+ rest = rest.slice(strike[0].length)
60
+ continue
61
+ }
62
+ const next = rest.search(/[\[*{_-]/)
63
+ if (next <= 0) {
64
+ const take = next === 0 ? 1 : rest.length
65
+ const last = nodes.at(-1)
66
+ if (last?.type === "text") last.value += rest.slice(0, take)
67
+ else nodes.push({ type: "text", value: rest.slice(0, take) })
68
+ rest = rest.slice(take)
69
+ continue
70
+ }
71
+ const last = nodes.at(-1)
72
+ if (last?.type === "text") last.value += rest.slice(0, next)
73
+ else nodes.push({ type: "text", value: rest.slice(0, next) })
74
+ rest = rest.slice(next)
75
+ }
76
+ return nodes
77
+ }
78
+
79
+ function splitCells(line: string, delim: "||" | "|") {
80
+ const trimmed = line.trim()
81
+ const inner =
82
+ delim === "||"
83
+ ? trimmed.replace(/^\|\|/, "").replace(/\|\|$/, "")
84
+ : trimmed.replace(/^\|/, "").replace(/\|$/, "")
85
+ return inner.split(delim === "||" ? "||" : "|").map((cell) => parseInline(cell.trim()))
86
+ }
87
+
88
+ function isTableRow(line: string) {
89
+ return line.startsWith("|") && line.endsWith("|") && line.length >= 2
90
+ }
91
+
92
+ function collectFence(lines: string[], start: number, close: string) {
93
+ const body: string[] = []
94
+ let i = start + 1
95
+ while (i < lines.length && lines[i]?.trim() !== close) {
96
+ body.push(lines[i] ?? "")
97
+ i += 1
98
+ }
99
+ return { body: body.join("\n"), end: i }
100
+ }
101
+
102
+ export function parseJiraWiki(input: string): WikiBlock[] {
103
+ const lines = input.replace(/\r\n/g, "\n").split("\n")
104
+ const blocks: WikiBlock[] = []
105
+ let i = 0
106
+ while (i < lines.length) {
107
+ const line = lines[i] ?? ""
108
+ const trimmed = line.trim()
109
+ if (!trimmed) {
110
+ i += 1
111
+ continue
112
+ }
113
+ if (trimmed === "----") {
114
+ blocks.push({ type: "hr" })
115
+ i += 1
116
+ continue
117
+ }
118
+ const heading = /^h([1-6])\.\s+(.*)$/.exec(trimmed)
119
+ if (heading) {
120
+ blocks.push({
121
+ type: "heading",
122
+ level: Number(heading[1]),
123
+ children: parseInline(heading[2] ?? ""),
124
+ })
125
+ i += 1
126
+ continue
127
+ }
128
+ const codeOpen = /^\{code(?::([a-zA-Z0-9+-]+))?\}$/.exec(trimmed)
129
+ if (codeOpen) {
130
+ const fence = collectFence(lines, i, "{code}")
131
+ blocks.push({ type: "code", language: codeOpen[1], value: fence.body })
132
+ i = fence.end + 1
133
+ continue
134
+ }
135
+ if (trimmed === "{noformat}") {
136
+ const fence = collectFence(lines, i, "{noformat}")
137
+ blocks.push({ type: "code", value: fence.body })
138
+ i = fence.end + 1
139
+ continue
140
+ }
141
+ if (trimmed === "{quote}") {
142
+ const fence = collectFence(lines, i, "{quote}")
143
+ blocks.push({ type: "quote", children: parseInline(fence.body.replace(/\n/g, " ")) })
144
+ i = fence.end + 1
145
+ continue
146
+ }
147
+ const panelOpen = /^\{panel(?::title=([^}]+))?\}$/.exec(trimmed)
148
+ if (panelOpen) {
149
+ const fence = collectFence(lines, i, "{panel}")
150
+ blocks.push({
151
+ type: "panel",
152
+ title: panelOpen[1],
153
+ children: parseJiraWiki(fence.body),
154
+ })
155
+ i = fence.end + 1
156
+ continue
157
+ }
158
+ if (isTableRow(trimmed)) {
159
+ const collected: { header: boolean; cells: WikiInline[][] }[] = []
160
+ while (i < lines.length) {
161
+ const row = lines[i]?.trim() ?? ""
162
+ if (!isTableRow(row)) break
163
+ const header = row.startsWith("||")
164
+ collected.push({ header, cells: splitCells(row, header ? "||" : "|") })
165
+ i += 1
166
+ }
167
+ const headerRows = collected.filter((row) => row.header).map((row) => row.cells)
168
+ const rows = collected.filter((row) => !row.header).map((row) => row.cells)
169
+ const header = headerRows[0] ?? rows.shift() ?? []
170
+ blocks.push({ type: "table", header, rows })
171
+ continue
172
+ }
173
+ if (/^[*#-]\s+/.test(trimmed)) {
174
+ const ordered = trimmed.startsWith("#")
175
+ const items: WikiInline[][] = []
176
+ while (i < lines.length) {
177
+ const item = lines[i]?.trim() ?? ""
178
+ const match = ordered ? /^#\s+(.*)$/.exec(item) : /^[*#-]\s+(.*)$/.exec(item)
179
+ if (!match) break
180
+ items.push(parseInline(match[1] ?? ""))
181
+ i += 1
182
+ }
183
+ blocks.push({ type: "list", ordered, items })
184
+ continue
185
+ }
186
+ const para: string[] = [trimmed]
187
+ i += 1
188
+ while (i < lines.length) {
189
+ const next = lines[i]?.trim() ?? ""
190
+ if (!next || /^(h[1-6]\.|----|\{code|\{panel|\{quote|\{noformat|\||[*#-]\s)/.test(next)) break
191
+ para.push(next)
192
+ i += 1
193
+ }
194
+ blocks.push({ type: "paragraph", children: parseInline(para.join(" ")) })
195
+ }
196
+ return blocks
197
+ }
@@ -0,0 +1,285 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { Paperclip } from "lucide-react"
5
+
6
+ import { cn } from "@monkey-mini-app/ui/lib/utils"
7
+ import { useLabels } from "@monkey-mini-app/ui/i18n/context"
8
+ import {
9
+ parseJiraWiki,
10
+ type WikiBlock,
11
+ type WikiInline,
12
+ } from "./jira-wiki-parse"
13
+
14
+ export type JiraWikiClassNames = {
15
+ root?: string
16
+ heading?: string
17
+ paragraph?: string
18
+ list?: string
19
+ listItem?: string
20
+ code?: string
21
+ inlineCode?: string
22
+ quote?: string
23
+ panel?: string
24
+ panelTitle?: string
25
+ table?: string
26
+ th?: string
27
+ td?: string
28
+ hr?: string
29
+ link?: string
30
+ attachment?: string
31
+ strike?: string
32
+ bold?: string
33
+ italic?: string
34
+ }
35
+
36
+ function InlineView({
37
+ nodes,
38
+ classNames,
39
+ onAttachmentClick,
40
+ }: {
41
+ nodes: WikiInline[]
42
+ classNames?: JiraWikiClassNames
43
+ onAttachmentClick?: (name: string) => void
44
+ }) {
45
+ return (
46
+ <>
47
+ {nodes.map((node, index) => {
48
+ if (node.type === "text") return <span key={index}>{node.value}</span>
49
+ if (node.type === "bold")
50
+ return (
51
+ <strong key={index} className={cn("font-semibold", classNames?.bold)}>
52
+ <InlineView nodes={node.children} classNames={classNames} onAttachmentClick={onAttachmentClick} />
53
+ </strong>
54
+ )
55
+ if (node.type === "italic")
56
+ return (
57
+ <em key={index} className={cn("italic", classNames?.italic)}>
58
+ <InlineView nodes={node.children} classNames={classNames} onAttachmentClick={onAttachmentClick} />
59
+ </em>
60
+ )
61
+ if (node.type === "strike")
62
+ return (
63
+ <s key={index} className={cn("text-muted-foreground", classNames?.strike)}>
64
+ <InlineView nodes={node.children} classNames={classNames} onAttachmentClick={onAttachmentClick} />
65
+ </s>
66
+ )
67
+ if (node.type === "code")
68
+ return (
69
+ <code
70
+ key={index}
71
+ className={cn("rounded-md bg-muted px-1 py-0.5 font-mono text-[0.85em]", classNames?.inlineCode)}
72
+ >
73
+ {node.value}
74
+ </code>
75
+ )
76
+ if (node.type === "attachment")
77
+ return (
78
+ <button
79
+ key={index}
80
+ type="button"
81
+ className={cn(
82
+ "inline-flex items-center gap-1 rounded-md border bg-muted/50 px-1.5 py-0.5 align-middle text-xs font-medium hover:bg-muted",
83
+ classNames?.attachment
84
+ )}
85
+ onClick={() => onAttachmentClick?.(node.name)}
86
+ >
87
+ <Paperclip className="size-3" />
88
+ {node.name}
89
+ </button>
90
+ )
91
+ return (
92
+ <a
93
+ key={index}
94
+ href={node.href}
95
+ className={cn("text-primary underline decoration-primary/40 underline-offset-2 hover:decoration-primary", classNames?.link)}
96
+ target="_blank"
97
+ rel="noreferrer"
98
+ >
99
+ {node.label}
100
+ </a>
101
+ )
102
+ })}
103
+ </>
104
+ )
105
+ }
106
+
107
+ function Blocks({
108
+ blocks,
109
+ classNames,
110
+ onAttachmentClick,
111
+ }: {
112
+ blocks: WikiBlock[]
113
+ classNames?: JiraWikiClassNames
114
+ onAttachmentClick?: (name: string) => void
115
+ }) {
116
+ const inline = { classNames, onAttachmentClick }
117
+ return (
118
+ <>
119
+ {blocks.map((block, index) => {
120
+ if (block.type === "heading") {
121
+ const level = Math.min(6, Math.max(1, block.level))
122
+ return React.createElement(
123
+ `h${level}`,
124
+ {
125
+ key: index,
126
+ className: cn(
127
+ "font-semibold tracking-tight first:mt-0",
128
+ level <= 1 && "mt-6 mb-2 text-xl",
129
+ level === 2 && "mt-5 mb-2 text-base",
130
+ level >= 3 && "mt-4 mb-1.5 text-sm",
131
+ classNames?.heading
132
+ ),
133
+ },
134
+ <InlineView nodes={block.children} {...inline} />
135
+ )
136
+ }
137
+ if (block.type === "paragraph")
138
+ return (
139
+ <p key={index} className={cn("mb-3 last:mb-0", classNames?.paragraph)}>
140
+ <InlineView nodes={block.children} {...inline} />
141
+ </p>
142
+ )
143
+ if (block.type === "list") {
144
+ const Tag = block.ordered ? "ol" : "ul"
145
+ return (
146
+ <Tag
147
+ key={index}
148
+ className={cn(
149
+ "mb-3 ml-5 space-y-1 last:mb-0",
150
+ block.ordered ? "list-decimal" : "list-disc",
151
+ classNames?.list
152
+ )}
153
+ >
154
+ {block.items.map((item, itemIndex) => (
155
+ <li key={itemIndex} className={cn("pl-0.5", classNames?.listItem)}>
156
+ <InlineView nodes={item} {...inline} />
157
+ </li>
158
+ ))}
159
+ </Tag>
160
+ )
161
+ }
162
+ if (block.type === "code")
163
+ return (
164
+ <pre
165
+ key={index}
166
+ className={cn(
167
+ "mb-3 overflow-auto rounded-lg border bg-muted/40 p-3 font-mono text-[0.85em] last:mb-0",
168
+ classNames?.code
169
+ )}
170
+ >
171
+ <code>{block.value}</code>
172
+ </pre>
173
+ )
174
+ if (block.type === "quote")
175
+ return (
176
+ <blockquote
177
+ key={index}
178
+ className={cn(
179
+ "mb-3 border-l-2 border-foreground/20 pl-3 text-muted-foreground italic last:mb-0",
180
+ classNames?.quote
181
+ )}
182
+ >
183
+ <InlineView nodes={block.children} {...inline} />
184
+ </blockquote>
185
+ )
186
+ if (block.type === "panel")
187
+ return (
188
+ <div
189
+ key={index}
190
+ className={cn(
191
+ "mb-3 rounded-lg border border-l-2 border-l-primary/70 bg-muted/30 px-3 py-2 last:mb-0",
192
+ classNames?.panel
193
+ )}
194
+ >
195
+ {block.title ? (
196
+ <div className={cn("mb-1 text-xs font-semibold tracking-wide uppercase", classNames?.panelTitle)}>
197
+ {block.title}
198
+ </div>
199
+ ) : null}
200
+ <Blocks blocks={block.children} classNames={classNames} onAttachmentClick={onAttachmentClick} />
201
+ </div>
202
+ )
203
+ if (block.type === "table")
204
+ return (
205
+ <div key={index} className="mb-3 overflow-x-auto last:mb-0">
206
+ <table className={cn("w-full border-collapse overflow-hidden rounded-lg text-sm", classNames?.table)}>
207
+ {block.header.length ? (
208
+ <thead>
209
+ <tr>
210
+ {block.header.map((cell, cellIndex) => (
211
+ <th
212
+ key={cellIndex}
213
+ className={cn(
214
+ "border border-border bg-muted/60 px-2.5 py-1.5 text-left text-xs font-semibold",
215
+ classNames?.th
216
+ )}
217
+ >
218
+ <InlineView nodes={cell} {...inline} />
219
+ </th>
220
+ ))}
221
+ </tr>
222
+ </thead>
223
+ ) : null}
224
+ <tbody>
225
+ {block.rows.map((row, rowIndex) => (
226
+ <tr key={rowIndex}>
227
+ {row.map((cell, cellIndex) => (
228
+ <td
229
+ key={cellIndex}
230
+ className={cn("border border-border px-2.5 py-1.5", classNames?.td)}
231
+ >
232
+ <InlineView nodes={cell} {...inline} />
233
+ </td>
234
+ ))}
235
+ </tr>
236
+ ))}
237
+ </tbody>
238
+ </table>
239
+ </div>
240
+ )
241
+ return <hr key={index} className={cn("my-4 border-border", classNames?.hr)} />
242
+ })}
243
+ </>
244
+ )
245
+ }
246
+
247
+ /**
248
+ * Render Jira wiki markup to tokens (headings, lists, tables, [^file], -strike-).
249
+ * Style via className / classNames; full control via parseJiraWiki().
250
+ * @when Jira description / comment body
251
+ * @example
252
+ * <JiraWiki classNames={{ heading: "text-base" }}>{`h2. Title\n* item`}</JiraWiki>
253
+ */
254
+ export function JiraWiki({
255
+ children,
256
+ className,
257
+ classNames,
258
+ onAttachmentClick,
259
+ }: {
260
+ children: string
261
+ className?: string
262
+ classNames?: JiraWikiClassNames
263
+ onAttachmentClick?: (name: string) => void
264
+ }) {
265
+ const t = useLabels("jiraWiki")
266
+ const blocks = parseJiraWiki(children)
267
+ if (!blocks.length) {
268
+ return (
269
+ <p data-testid="jira-wiki" className={cn("text-muted-foreground text-sm", classNames?.root, className)}>
270
+ {t.empty}
271
+ </p>
272
+ )
273
+ }
274
+ return (
275
+ <div
276
+ data-testid="jira-wiki"
277
+ className={cn("text-sm leading-relaxed text-foreground", classNames?.root, className)}
278
+ >
279
+ <Blocks blocks={blocks} classNames={classNames} onAttachmentClick={onAttachmentClick} />
280
+ </div>
281
+ )
282
+ }
283
+
284
+ export { parseJiraWiki } from "./jira-wiki-parse"
285
+ export type { WikiBlock, WikiInline } from "./jira-wiki-parse"
@@ -0,0 +1,62 @@
1
+ "use client"
2
+
3
+ import { oneDark } from "@codemirror/theme-one-dark"
4
+ import { autocompletion } from "@codemirror/autocomplete"
5
+ import CodeMirror from "@uiw/react-codemirror"
6
+
7
+ import { cn } from "@monkey-mini-app/ui/lib/utils"
8
+ import { useLabels } from "@monkey-mini-app/ui/i18n/context"
9
+ import {
10
+ defaultJqlFields,
11
+ defaultJqlFunctions,
12
+ jqlCompletion,
13
+ jqlLanguage,
14
+ type JqlSuggestItem,
15
+ } from "./jql-language"
16
+
17
+ /**
18
+ * CodeMirror JQL editor with field/function completion (host injects fields).
19
+ * @when Issue search / saved filters
20
+ * @example
21
+ * <JqlInput value={jql} onChange={setJql} fields={[{ name: "status" }]} />
22
+ */
23
+ export function JqlInput({
24
+ value,
25
+ onChange,
26
+ fields = defaultJqlFields,
27
+ functions = defaultJqlFunctions,
28
+ height = "88px",
29
+ className,
30
+ }: {
31
+ value: string
32
+ onChange?: (value: string) => void
33
+ fields?: JqlSuggestItem[]
34
+ functions?: JqlSuggestItem[]
35
+ height?: string
36
+ className?: string
37
+ }) {
38
+ const t = useLabels("jqlInput")
39
+ const dark =
40
+ typeof document !== "undefined" &&
41
+ document.documentElement.classList.contains("dark")
42
+
43
+ return (
44
+ <div data-testid="jql-input" className={cn("overflow-hidden rounded-xl border", className)}>
45
+ <CodeMirror
46
+ value={value}
47
+ height={height}
48
+ theme={dark ? oneDark : "light"}
49
+ placeholder={t.placeholder}
50
+ extensions={[
51
+ jqlLanguage,
52
+ autocompletion({ override: [jqlCompletion(fields, functions)] }),
53
+ ]}
54
+ onChange={(next) => onChange?.(next)}
55
+ basicSetup={{ lineNumbers: false, foldGutter: false }}
56
+ />
57
+ </div>
58
+ )
59
+ }
60
+
61
+ export { defaultJqlFields, defaultJqlFunctions } from "./jql-language"
62
+ export type { JqlSuggestItem }