@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,70 @@
1
+ import { StreamLanguage } from "@codemirror/language"
2
+ import type { CompletionContext } from "@codemirror/autocomplete"
3
+
4
+ const KEYWORDS =
5
+ /^(AND|OR|NOT|ORDER|BY|ASC|DESC|IN|IS|EMPTY|NULL|WAS|CHANGED)\b/i
6
+ const OPERATORS = /^(!=|!~|>=|<=|=|~|>|<)/
7
+
8
+ export const jqlLanguage = StreamLanguage.define({
9
+ token(stream) {
10
+ if (stream.eatSpace()) return null
11
+ if (stream.match(/"(?:[^"\\]|\\.)*"/) || stream.match(/'(?:[^'\\]|\\.)*'/)) return "string"
12
+ if (stream.match(/^[0-9]+(\.[0-9]+)?/)) return "number"
13
+ if (stream.match(KEYWORDS)) return "keyword"
14
+ if (stream.match(OPERATORS)) return "operator"
15
+ if (stream.match(/^[(),[\]]/)) return "punctuation"
16
+ if (stream.match(/^[A-Za-z_][A-Za-z0-9_]*(?=\s*\()/)) return "function"
17
+ if (stream.match(/^[A-Za-z][A-Za-z0-9_.]*/)) return "variableName"
18
+ stream.next()
19
+ return null
20
+ },
21
+ })
22
+
23
+ export type JqlSuggestItem = { name: string; detail?: string }
24
+
25
+ export function jqlCompletion(fields: JqlSuggestItem[], functions: JqlSuggestItem[]) {
26
+ const keywords = ["AND", "OR", "NOT", "ORDER BY", "ASC", "DESC", "IN", "NOT IN", "IS", "IS NOT", "EMPTY"]
27
+ return (context: CompletionContext) => {
28
+ const word = context.matchBefore(/[A-Za-z_][\w.]*/)
29
+ if (!word && !context.explicit) return null
30
+ return {
31
+ from: word?.from ?? context.pos,
32
+ options: [
33
+ ...fields.map((field) => ({
34
+ label: field.name,
35
+ type: "property",
36
+ detail: field.detail,
37
+ })),
38
+ ...functions.map((fn) => ({
39
+ label: `${fn.name}()`,
40
+ type: "function",
41
+ detail: fn.detail,
42
+ })),
43
+ ...keywords.map((label) => ({ label, type: "keyword" })),
44
+ ],
45
+ }
46
+ }
47
+ }
48
+
49
+ export const defaultJqlFields: JqlSuggestItem[] = [
50
+ { name: "project", detail: "Project key" },
51
+ { name: "status", detail: "Status" },
52
+ { name: "assignee", detail: "Assignee" },
53
+ { name: "reporter", detail: "Reporter" },
54
+ { name: "issuetype", detail: "Issue type" },
55
+ { name: "priority", detail: "Priority" },
56
+ { name: "labels", detail: "Labels" },
57
+ { name: "fixVersion", detail: "Fix version" },
58
+ { name: "created", detail: "Created" },
59
+ { name: "updated", detail: "Updated" },
60
+ { name: "summary", detail: "Summary" },
61
+ { name: "key", detail: "Issue key" },
62
+ ]
63
+
64
+ export const defaultJqlFunctions: JqlSuggestItem[] = [
65
+ { name: "currentUser", detail: "Logged-in user" },
66
+ { name: "now", detail: "Current time" },
67
+ { name: "startOfDay", detail: "Start of day" },
68
+ { name: "endOfDay", detail: "End of day" },
69
+ { name: "membersOf", detail: "Group members" },
70
+ ]
@@ -0,0 +1,37 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+
5
+ function Node({ label, value, depth }: { label?: string; value: unknown; depth: number }) {
6
+ const [open, setOpen] = React.useState(depth < 2)
7
+ if (value !== null && typeof value === "object") {
8
+ const entries = Object.entries(value as Record<string, unknown>)
9
+ return (
10
+ <div style={{ paddingLeft: depth * 12 }}>
11
+ <button type="button" className="text-left font-mono text-xs" onClick={() => setOpen((v) => !v)}>
12
+ {open ? "▾" : "▸"} {label ? `${label}: ` : ""}
13
+ {Array.isArray(value) ? `Array(${entries.length})` : "Object"}
14
+ </button>
15
+ {open
16
+ ? entries.map(([key, child]) => (
17
+ <Node key={key} label={key} value={child} depth={depth + 1} />
18
+ ))
19
+ : null}
20
+ </div>
21
+ )
22
+ }
23
+ return (
24
+ <div className="font-mono text-xs" style={{ paddingLeft: depth * 12 }}>
25
+ {label ? `${label}: ` : ""}
26
+ <span className="text-muted-foreground">{JSON.stringify(value)}</span>
27
+ </div>
28
+ )
29
+ }
30
+
31
+ export function JsonViewer({ value }: { value: unknown }) {
32
+ return (
33
+ <div data-testid="json-viewer" className="rounded-xl border bg-card p-3">
34
+ <Node value={value} depth={0} />
35
+ </div>
36
+ )
37
+ }
@@ -0,0 +1,98 @@
1
+ "use client"
2
+
3
+ import { Avatar, AvatarFallback } from "@monkey-mini-app/ui/components/avatar"
4
+ import { Badge } from "@monkey-mini-app/ui/components/badge"
5
+ import { Separator } from "@monkey-mini-app/ui/components/separator"
6
+ import {
7
+ Sheet,
8
+ SheetContent,
9
+ SheetDescription,
10
+ SheetHeader,
11
+ SheetTitle,
12
+ } from "@monkey-mini-app/ui/components/sheet"
13
+ import { CommentThread } from "@monkey-mini-app/ui/blocks/comment-thread"
14
+ import { DescriptionList } from "@monkey-mini-app/ui/blocks/description-list"
15
+ import { SeverityChip } from "@monkey-mini-app/ui/blocks/severity-chip"
16
+ import { StatusBadge } from "@monkey-mini-app/ui/blocks/status-badge"
17
+ import type { KanbanCard } from "@monkey-mini-app/ui/products/kanban"
18
+ import { useLabels } from "@monkey-mini-app/ui/i18n/context"
19
+
20
+ export function KanbanIssuePanel({
21
+ card,
22
+ columnTitle,
23
+ open,
24
+ onOpenChange,
25
+ }: {
26
+ card: KanbanCard | null
27
+ columnTitle?: string
28
+ open: boolean
29
+ onOpenChange: (open: boolean) => void
30
+ }) {
31
+ const t = useLabels("kanbanIssue")
32
+ if (!card) return null
33
+ return (
34
+ <Sheet open={open} onOpenChange={onOpenChange}>
35
+ <SheetContent className="sm:max-w-lg" data-testid="kanban-issue-panel">
36
+ <SheetHeader>
37
+ <SheetDescription className="font-mono text-xs">
38
+ {card.key ?? card.id}
39
+ {card.type ? ` · ${card.type}` : ""}
40
+ </SheetDescription>
41
+ <SheetTitle>{card.title}</SheetTitle>
42
+ </SheetHeader>
43
+ <div className="flex flex-1 flex-col gap-4 overflow-y-auto px-4 pb-6">
44
+ <div className="flex flex-wrap gap-2">
45
+ {card.status ? <StatusBadge status={card.status} /> : null}
46
+ {card.priority ? <SeverityChip severity={card.priority} /> : null}
47
+ {columnTitle ? <Badge variant="outline">{columnTitle}</Badge> : null}
48
+ </div>
49
+ <DescriptionList
50
+ items={[
51
+ { label: t.assignee, value: card.assignee ?? t.unassigned },
52
+ { label: t.reporter, value: card.reporter ?? "—" },
53
+ { label: t.updated, value: card.updated ?? "—" },
54
+ ]}
55
+ />
56
+ {card.tags?.length ? (
57
+ <div className="flex flex-wrap gap-1">
58
+ {card.tags.map((tag) => (
59
+ <Badge key={tag} variant="secondary">
60
+ {tag}
61
+ </Badge>
62
+ ))}
63
+ </div>
64
+ ) : null}
65
+ <div>
66
+ <div className="mb-1 text-xs font-medium tracking-wide text-muted-foreground uppercase">
67
+ {t.description}
68
+ </div>
69
+ <p className="text-sm leading-6 whitespace-pre-wrap">
70
+ {card.description ?? t.noDescription}
71
+ </p>
72
+ </div>
73
+ <Separator />
74
+ <div>
75
+ <div className="mb-2 text-xs font-medium tracking-wide text-muted-foreground uppercase">
76
+ {t.comments}
77
+ </div>
78
+ {card.comments?.length ? (
79
+ <CommentThread comments={card.comments} />
80
+ ) : (
81
+ <p className="text-muted-foreground text-sm">{t.noComments}</p>
82
+ )}
83
+ </div>
84
+ {card.assignee ? (
85
+ <div className="mt-auto flex items-center gap-2 text-sm">
86
+ <Avatar className="size-6">
87
+ <AvatarFallback className="text-[10px]">
88
+ {card.assignee.slice(0, 2).toUpperCase()}
89
+ </AvatarFallback>
90
+ </Avatar>
91
+ {card.assignee}
92
+ </div>
93
+ ) : null}
94
+ </div>
95
+ </SheetContent>
96
+ </Sheet>
97
+ )
98
+ }
@@ -0,0 +1,245 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import type { ReactNode } from "react"
5
+ import {
6
+ DndContext,
7
+ PointerSensor,
8
+ closestCorners,
9
+ useDraggable,
10
+ useDroppable,
11
+ useSensor,
12
+ useSensors,
13
+ type DragEndEvent,
14
+ } from "@dnd-kit/core"
15
+ import { CSS } from "@dnd-kit/utilities"
16
+
17
+ import { Badge } from "@monkey-mini-app/ui/components/badge"
18
+ import { Avatar, AvatarFallback } from "@monkey-mini-app/ui/components/avatar"
19
+ import { cn } from "@monkey-mini-app/ui/lib/utils"
20
+
21
+ export type KanbanComment = {
22
+ id: string
23
+ author: string
24
+ body: string
25
+ time?: string
26
+ }
27
+
28
+ export type KanbanCard = {
29
+ id: string
30
+ title: string
31
+ columnId: string
32
+ key?: string
33
+ type?: string
34
+ status?: string
35
+ description?: string
36
+ assignee?: string
37
+ reporter?: string
38
+ tags?: string[]
39
+ priority?: "P0" | "P1" | "P2" | "P3" | string
40
+ updated?: string
41
+ comments?: KanbanComment[]
42
+ }
43
+
44
+ export type KanbanColumn = {
45
+ id: string
46
+ title: string
47
+ limit?: number
48
+ }
49
+
50
+ const priorityTone: Record<string, string> = {
51
+ P0: "bg-destructive/15 text-destructive",
52
+ P1: "bg-orange-500/15 text-orange-700 dark:text-orange-400",
53
+ P2: "bg-amber-500/15 text-amber-700 dark:text-amber-400",
54
+ P3: "bg-muted text-muted-foreground",
55
+ }
56
+
57
+ function DefaultCard({ card }: { card: KanbanCard }) {
58
+ return (
59
+ <>
60
+ {card.key ? (
61
+ <div className="text-muted-foreground mb-1 font-mono text-[10px]">{card.key}</div>
62
+ ) : null}
63
+ <div className="flex items-start justify-between gap-2">
64
+ <div className="text-sm font-medium">{card.title}</div>
65
+ {card.priority ? (
66
+ <Badge
67
+ variant="outline"
68
+ className={cn(
69
+ "border-transparent text-[10px]",
70
+ priorityTone[card.priority] ?? "bg-muted"
71
+ )}
72
+ >
73
+ {card.priority}
74
+ </Badge>
75
+ ) : null}
76
+ </div>
77
+ {card.description ? (
78
+ <p className="text-muted-foreground line-clamp-2 text-xs">{card.description}</p>
79
+ ) : null}
80
+ <div className="mt-2 flex items-center justify-between gap-2">
81
+ <div className="flex flex-wrap gap-1">
82
+ {card.tags?.map((tag) => (
83
+ <Badge key={tag} variant="secondary" className="text-[10px]">
84
+ {tag}
85
+ </Badge>
86
+ ))}
87
+ </div>
88
+ {card.assignee ? (
89
+ <Avatar className="size-5">
90
+ <AvatarFallback className="text-[9px]">
91
+ {card.assignee.slice(0, 2).toUpperCase()}
92
+ </AvatarFallback>
93
+ </Avatar>
94
+ ) : null}
95
+ </div>
96
+ </>
97
+ )
98
+ }
99
+
100
+ function Card({
101
+ card,
102
+ children,
103
+ onCardClick,
104
+ }: {
105
+ card: KanbanCard
106
+ children: ReactNode
107
+ onCardClick?: (card: KanbanCard) => void
108
+ }) {
109
+ const origin = React.useRef({ x: 0, y: 0 })
110
+ const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
111
+ id: card.id,
112
+ })
113
+ return (
114
+ <div
115
+ ref={setNodeRef}
116
+ data-testid={`kanban-card-${card.id}`}
117
+ style={{ transform: CSS.Translate.toString(transform) }}
118
+ className={cn(
119
+ "cursor-pointer rounded-lg border bg-card px-2.5 py-2 text-left shadow-xs",
120
+ isDragging && "opacity-70"
121
+ )}
122
+ {...attributes}
123
+ {...listeners}
124
+ onPointerDown={(event) => {
125
+ origin.current = { x: event.clientX, y: event.clientY }
126
+ listeners?.onPointerDown?.(event)
127
+ }}
128
+ onClick={(event) => {
129
+ if (
130
+ Math.hypot(
131
+ event.clientX - origin.current.x,
132
+ event.clientY - origin.current.y
133
+ ) > 6
134
+ ) {
135
+ return
136
+ }
137
+ onCardClick?.(card)
138
+ }}
139
+ >
140
+ {children}
141
+ </div>
142
+ )
143
+ }
144
+
145
+ function Column({
146
+ column,
147
+ cards,
148
+ renderCard,
149
+ onCardClick,
150
+ }: {
151
+ column: KanbanColumn
152
+ cards: KanbanCard[]
153
+ renderCard?: (card: KanbanCard) => ReactNode
154
+ onCardClick?: (card: KanbanCard) => void
155
+ }) {
156
+ const { setNodeRef, isOver } = useDroppable({ id: column.id })
157
+ const overLimit = column.limit != null && cards.length > column.limit
158
+ return (
159
+ <div
160
+ className={cn(
161
+ "flex w-72 shrink-0 flex-col gap-2 rounded-xl border bg-muted/30 p-2",
162
+ isOver && "border-primary/50"
163
+ )}
164
+ >
165
+ <div className="flex items-center justify-between px-1">
166
+ <div className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
167
+ {column.title}
168
+ </div>
169
+ <span
170
+ className={cn(
171
+ "text-[11px] tabular-nums text-muted-foreground",
172
+ overLimit && "text-destructive"
173
+ )}
174
+ >
175
+ {cards.length}
176
+ {column.limit != null ? ` / ${column.limit}` : ""}
177
+ </span>
178
+ </div>
179
+ <div ref={setNodeRef} className="flex min-h-32 flex-col gap-2">
180
+ {cards.map((card) => (
181
+ <Card key={card.id} card={card} onCardClick={onCardClick}>
182
+ {renderCard ? renderCard(card) : <DefaultCard card={card} />}
183
+ </Card>
184
+ ))}
185
+ </div>
186
+ </div>
187
+ )
188
+ }
189
+
190
+ /**
191
+ * DnD board: columns + cards. Move cards across columns via drag.
192
+ * @when Issue/workflow boards
193
+ * @example
194
+ * <Kanban columns={cols} cards={cards} onCardsChange={setCards} onCardClick={open} />
195
+ */
196
+ export function Kanban({
197
+ columns,
198
+ cards,
199
+ onCardsChange,
200
+ renderCard,
201
+ onCardClick,
202
+ }: {
203
+ columns: KanbanColumn[]
204
+ cards: KanbanCard[]
205
+ onCardsChange?: (cards: KanbanCard[]) => void
206
+ renderCard?: (card: KanbanCard) => ReactNode
207
+ onCardClick?: (card: KanbanCard) => void
208
+ }) {
209
+ const sensors = useSensors(
210
+ useSensor(PointerSensor, { activationConstraint: { distance: 6 } })
211
+ )
212
+ const onDragEnd = (event: DragEndEvent) => {
213
+ const { active, over } = event
214
+ if (!over) return
215
+ const overId = String(over.id)
216
+ const overColumn = columns.some((column) => column.id === overId)
217
+ ? overId
218
+ : cards.find((card) => card.id === overId)?.columnId
219
+ if (!overColumn) return
220
+ onCardsChange?.(
221
+ cards.map((card) =>
222
+ card.id === String(active.id) ? { ...card, columnId: overColumn } : card
223
+ )
224
+ )
225
+ }
226
+ return (
227
+ <DndContext
228
+ sensors={sensors}
229
+ collisionDetection={closestCorners}
230
+ onDragEnd={onDragEnd}
231
+ >
232
+ <div className="flex gap-3 overflow-x-auto pb-2" data-testid="kanban">
233
+ {columns.map((column) => (
234
+ <Column
235
+ key={column.id}
236
+ column={column}
237
+ cards={cards.filter((card) => card.columnId === column.id)}
238
+ renderCard={renderCard}
239
+ onCardClick={onCardClick}
240
+ />
241
+ ))}
242
+ </div>
243
+ </DndContext>
244
+ )
245
+ }
@@ -0,0 +1,33 @@
1
+ "use client"
2
+
3
+ import { useVirtualizer } from "@tanstack/react-virtual"
4
+ import * as React from "react"
5
+
6
+ export function LogViewer({ lines }: { lines: string[] }) {
7
+ const parentRef = React.useRef<HTMLDivElement>(null)
8
+ const virtualizer = useVirtualizer({
9
+ count: lines.length,
10
+ getScrollElement: () => parentRef.current,
11
+ estimateSize: () => 22,
12
+ overscan: 12,
13
+ })
14
+ return (
15
+ <div
16
+ ref={parentRef}
17
+ data-testid="log-viewer"
18
+ className="h-64 overflow-auto rounded-xl border bg-card font-mono text-xs"
19
+ >
20
+ <div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
21
+ {virtualizer.getVirtualItems().map((item) => (
22
+ <div
23
+ key={item.key}
24
+ className="absolute left-0 w-full truncate px-3"
25
+ style={{ top: item.start, height: item.size }}
26
+ >
27
+ {lines[item.index]}
28
+ </div>
29
+ ))}
30
+ </div>
31
+ </div>
32
+ )
33
+ }
@@ -0,0 +1,63 @@
1
+ "use client"
2
+
3
+ import { Button } from "@monkey-mini-app/ui/components/button"
4
+ import { CodeEditor } from "@monkey-mini-app/ui/products/code-editor"
5
+ import { Markdown } from "@monkey-mini-app/ui/products/markdown"
6
+ import { cn } from "@monkey-mini-app/ui/lib/utils"
7
+ import { useLabels } from "@monkey-mini-app/ui/i18n/context"
8
+
9
+ export type MarkdownEditorMode = "edit" | "split" | "preview"
10
+
11
+ export function MarkdownEditor({
12
+ value,
13
+ onChange,
14
+ mode = "split",
15
+ onModeChange,
16
+ }: {
17
+ value: string
18
+ onChange?: (value: string) => void
19
+ mode?: MarkdownEditorMode
20
+ onModeChange?: (mode: MarkdownEditorMode) => void
21
+ }) {
22
+ const t = useLabels("markdownEditor")
23
+ return (
24
+ <div data-testid="markdown-editor" className="overflow-hidden rounded-xl border bg-card">
25
+ <div className="flex items-center gap-1 border-b p-1">
26
+ {(["edit", "split", "preview"] as const).map((item) => (
27
+ <Button
28
+ key={item}
29
+ size="sm"
30
+ variant={mode === item ? "default" : "ghost"}
31
+ data-testid={`markdown-mode-${item}`}
32
+ onClick={() => onModeChange?.(item)}
33
+ >
34
+ {t[item]}
35
+ </Button>
36
+ ))}
37
+ </div>
38
+ <div
39
+ className={cn(
40
+ "grid min-h-[280px]",
41
+ mode === "split" ? "md:grid-cols-2" : "grid-cols-1"
42
+ )}
43
+ >
44
+ {mode !== "preview" ? (
45
+ <div className={cn(mode === "split" && "border-b md:border-r md:border-b-0")}>
46
+ <CodeEditor
47
+ value={value}
48
+ onChange={onChange}
49
+ language="md"
50
+ height="280px"
51
+ className="rounded-none border-0"
52
+ />
53
+ </div>
54
+ ) : null}
55
+ {mode !== "edit" ? (
56
+ <div className="overflow-auto p-3" data-testid="markdown-preview">
57
+ <Markdown>{value}</Markdown>
58
+ </div>
59
+ ) : null}
60
+ </div>
61
+ </div>
62
+ )
63
+ }
@@ -0,0 +1,26 @@
1
+ "use client"
2
+
3
+ import ReactMarkdown from "react-markdown"
4
+ import remarkGfm from "remark-gfm"
5
+
6
+ import { cn } from "@monkey-mini-app/ui/lib/utils"
7
+
8
+ export function Markdown({
9
+ children,
10
+ className,
11
+ }: {
12
+ children: string
13
+ className?: string
14
+ }) {
15
+ return (
16
+ <div
17
+ data-testid="markdown"
18
+ className={cn(
19
+ "text-sm [&_a]:underline [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_h1]:mb-2 [&_h1]:text-lg [&_h1]:font-medium [&_h2]:mb-2 [&_h2]:text-base [&_h2]:font-medium [&_li]:ml-4 [&_p]:mb-2 [&_pre]:overflow-auto [&_pre]:rounded-lg [&_pre]:border [&_pre]:bg-muted/40 [&_pre]:p-3 [&_ul]:list-disc",
20
+ className
21
+ )}
22
+ >
23
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>{children}</ReactMarkdown>
24
+ </div>
25
+ )
26
+ }
@@ -0,0 +1,84 @@
1
+ "use client"
2
+
3
+ import { useEditor, EditorContent } from "@tiptap/react"
4
+ import StarterKit from "@tiptap/starter-kit"
5
+ import Placeholder from "@tiptap/extension-placeholder"
6
+ import { Bold, Code, Italic, List, ListOrdered } from "lucide-react"
7
+
8
+ import { Toggle } from "@monkey-mini-app/ui/components/toggle"
9
+ import { cn } from "@monkey-mini-app/ui/lib/utils"
10
+ import { useLabels } from "@monkey-mini-app/ui/i18n/context"
11
+
12
+ export function RichTextEditor({
13
+ value = "<p></p>",
14
+ onChange,
15
+ placeholder,
16
+ }: {
17
+ value?: string
18
+ onChange?: (html: string) => void
19
+ placeholder?: string
20
+ }) {
21
+ const t = useLabels("richText")
22
+ const editor = useEditor({
23
+ immediatelyRender: false,
24
+ extensions: [StarterKit, Placeholder.configure({ placeholder: placeholder ?? t.placeholder })],
25
+ content: value,
26
+ onUpdate: ({ editor: instance }) => onChange?.(instance.getHTML()),
27
+ editorProps: {
28
+ attributes: {
29
+ class:
30
+ "min-h-32 px-3 py-2 text-sm outline-none [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_p]:my-1",
31
+ },
32
+ },
33
+ })
34
+
35
+ if (!editor) return <div data-testid="rich-text-editor" className="min-h-40 rounded-xl border" />
36
+
37
+ return (
38
+ <div data-testid="rich-text-editor" className="overflow-hidden rounded-xl border bg-card">
39
+ <div className="flex flex-wrap gap-0.5 border-b p-1">
40
+ <Toggle
41
+ size="sm"
42
+ pressed={editor.isActive("bold")}
43
+ aria-label={t.bold}
44
+ onPressedChange={() => editor.chain().focus().toggleBold().run()}
45
+ >
46
+ <Bold />
47
+ </Toggle>
48
+ <Toggle
49
+ size="sm"
50
+ pressed={editor.isActive("italic")}
51
+ aria-label={t.italic}
52
+ onPressedChange={() => editor.chain().focus().toggleItalic().run()}
53
+ >
54
+ <Italic />
55
+ </Toggle>
56
+ <Toggle
57
+ size="sm"
58
+ pressed={editor.isActive("bulletList")}
59
+ aria-label={t.bullet}
60
+ onPressedChange={() => editor.chain().focus().toggleBulletList().run()}
61
+ >
62
+ <List />
63
+ </Toggle>
64
+ <Toggle
65
+ size="sm"
66
+ pressed={editor.isActive("orderedList")}
67
+ aria-label={t.ordered}
68
+ onPressedChange={() => editor.chain().focus().toggleOrderedList().run()}
69
+ >
70
+ <ListOrdered />
71
+ </Toggle>
72
+ <Toggle
73
+ size="sm"
74
+ pressed={editor.isActive("code")}
75
+ aria-label={t.code}
76
+ onPressedChange={() => editor.chain().focus().toggleCode().run()}
77
+ >
78
+ <Code />
79
+ </Toggle>
80
+ </div>
81
+ <EditorContent editor={editor} className={cn("prose-sm max-w-none")} />
82
+ </div>
83
+ )
84
+ }