@monkey-mini-app/ui 0.1.1 → 0.1.4

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 (203) hide show
  1. package/README.md +12 -6
  2. package/dist/globals.css +1 -1
  3. package/dist/index.js +12 -2
  4. package/dist/manifest.json +6 -6
  5. package/dist/runtime.js +9 -0
  6. package/dist/sdk.js +158 -0
  7. package/dist/src/blocks/activity-feed.tsx +7 -0
  8. package/dist/src/blocks/app-shell.tsx +1 -0
  9. package/dist/src/blocks/attachment-gallery.tsx +7 -0
  10. package/dist/src/blocks/bar-chart.tsx +11 -0
  11. package/dist/src/blocks/comment-thread.tsx +7 -0
  12. package/dist/src/blocks/dashboard-shell.tsx +109 -0
  13. package/dist/src/blocks/description-list.tsx +7 -0
  14. package/dist/src/blocks/detail-panel.tsx +1 -0
  15. package/dist/src/blocks/donut-chart.tsx +7 -0
  16. package/dist/src/blocks/env-badge.tsx +7 -0
  17. package/dist/src/blocks/env-table.tsx +316 -0
  18. package/dist/src/blocks/file-tree.tsx +7 -0
  19. package/dist/src/blocks/filter-bar.tsx +1 -0
  20. package/dist/src/blocks/form-sheet.tsx +160 -0
  21. package/dist/src/blocks/gauge.tsx +7 -0
  22. package/dist/src/blocks/list-detail.tsx +121 -0
  23. package/dist/src/blocks/notification-center.tsx +7 -0
  24. package/dist/src/blocks/page-header.tsx +1 -0
  25. package/dist/src/blocks/progress-ring.tsx +7 -0
  26. package/dist/src/blocks/radar-chart.tsx +7 -0
  27. package/dist/src/blocks/request-inspector.tsx +7 -0
  28. package/dist/src/blocks/reveal.tsx +56 -0
  29. package/dist/src/blocks/run-timeline.tsx +7 -0
  30. package/dist/src/blocks/scrollspy.tsx +7 -0
  31. package/dist/src/blocks/settings-split.tsx +154 -0
  32. package/dist/src/blocks/severity-chip.tsx +7 -0
  33. package/dist/src/blocks/sparkline.tsx +7 -0
  34. package/dist/src/blocks/stat-card.tsx +1 -0
  35. package/dist/src/blocks/status-badge.tsx +1 -0
  36. package/dist/src/blocks/table-page.tsx +83 -0
  37. package/dist/src/blocks/terminal.tsx +21 -3
  38. package/dist/src/blocks/test-step-list.tsx +7 -0
  39. package/dist/src/blocks/trend-card.tsx +7 -0
  40. package/dist/src/blocks/wizard-shell.tsx +132 -0
  41. package/dist/src/cdn-modules.d.ts +5 -0
  42. package/dist/src/components/accordion.tsx +7 -0
  43. package/dist/src/components/alert-dialog.tsx +7 -0
  44. package/dist/src/components/alert.tsx +7 -0
  45. package/dist/src/components/aspect-ratio.tsx +4 -0
  46. package/dist/src/components/attachment.tsx +4 -0
  47. package/dist/src/components/avatar.tsx +4 -0
  48. package/dist/src/components/badge.tsx +7 -0
  49. package/dist/src/components/breadcrumb.tsx +4 -0
  50. package/dist/src/components/bubble.tsx +4 -0
  51. package/dist/src/components/button-group.tsx +6 -0
  52. package/dist/src/components/button.tsx +7 -0
  53. package/dist/src/components/calendar.tsx +4 -0
  54. package/dist/src/components/card.tsx +8 -1
  55. package/dist/src/components/chart.tsx +7 -0
  56. package/dist/src/components/checkbox.tsx +5 -0
  57. package/dist/src/components/collapsible.tsx +4 -0
  58. package/dist/src/components/combobox.tsx +4 -0
  59. package/dist/src/components/command.tsx +6 -0
  60. package/dist/src/components/context-menu.tsx +4 -0
  61. package/dist/src/components/dialog.tsx +21 -0
  62. package/dist/src/components/drawer.tsx +4 -0
  63. package/dist/src/components/dropdown-menu.tsx +7 -0
  64. package/dist/src/components/empty.tsx +7 -0
  65. package/dist/src/components/field.tsx +6 -0
  66. package/dist/src/components/hover-card.tsx +4 -0
  67. package/dist/src/components/input-group.tsx +4 -0
  68. package/dist/src/components/input.tsx +7 -0
  69. package/dist/src/components/item.tsx +4 -0
  70. package/dist/src/components/kbd.tsx +4 -0
  71. package/dist/src/components/label.tsx +7 -0
  72. package/dist/src/components/marker.tsx +4 -0
  73. package/dist/src/components/menubar.tsx +4 -0
  74. package/dist/src/components/message-scroller.tsx +4 -0
  75. package/dist/src/components/message.tsx +4 -0
  76. package/dist/src/components/native-select.tsx +4 -0
  77. package/dist/src/components/navigation-menu.tsx +4 -0
  78. package/dist/src/components/pagination.tsx +4 -0
  79. package/dist/src/components/popover.tsx +7 -0
  80. package/dist/src/components/progress.tsx +7 -0
  81. package/dist/src/components/questionnaire.tsx +4 -0
  82. package/dist/src/components/radio-group.tsx +4 -0
  83. package/dist/src/components/resizable.tsx +7 -0
  84. package/dist/src/components/scroll-area.tsx +4 -0
  85. package/dist/src/components/select.tsx +7 -0
  86. package/dist/src/components/separator.tsx +5 -0
  87. package/dist/src/components/sheet.tsx +22 -0
  88. package/dist/src/components/sidebar.tsx +4 -0
  89. package/dist/src/components/skeleton.tsx +7 -0
  90. package/dist/src/components/slider.tsx +4 -0
  91. package/dist/src/components/spinner.tsx +6 -0
  92. package/dist/src/components/switch.tsx +7 -0
  93. package/dist/src/components/table.tsx +7 -0
  94. package/dist/src/components/tabs.tsx +7 -0
  95. package/dist/src/components/textarea.tsx +7 -0
  96. package/dist/src/components/toast.tsx +7 -0
  97. package/dist/src/components/toggle-group.tsx +4 -0
  98. package/dist/src/components/toggle.tsx +4 -0
  99. package/dist/src/components/tooltip.tsx +7 -0
  100. package/dist/src/composites/autocomplete.tsx +7 -0
  101. package/dist/src/composites/cascader.tsx +7 -0
  102. package/dist/src/composites/color-picker.tsx +7 -0
  103. package/dist/src/composites/confirm-dialog.tsx +7 -0
  104. package/dist/src/composites/copyable.tsx +9 -1
  105. package/dist/src/composites/currency-input.tsx +7 -0
  106. package/dist/src/composites/date-picker.tsx +7 -0
  107. package/dist/src/composites/date-range-picker.tsx +4 -0
  108. package/dist/src/composites/date-time-picker.tsx +7 -0
  109. package/dist/src/composites/date-time-range-picker.tsx +7 -0
  110. package/dist/src/composites/duration-input.tsx +7 -0
  111. package/dist/src/composites/mini-calendar.tsx +7 -0
  112. package/dist/src/composites/number-field.tsx +7 -0
  113. package/dist/src/composites/password-field.tsx +7 -0
  114. package/dist/src/composites/phone-input.tsx +7 -0
  115. package/dist/src/composites/rating.tsx +7 -0
  116. package/dist/src/composites/relative-date-picker.tsx +7 -0
  117. package/dist/src/composites/search-input.tsx +7 -0
  118. package/dist/src/composites/slider-range.tsx +7 -0
  119. package/dist/src/composites/tag-input.tsx +7 -0
  120. package/dist/src/composites/time-picker.tsx +11 -1
  121. package/dist/src/composites/time-range-picker.tsx +7 -0
  122. package/dist/src/composites/timezone-select.tsx +7 -0
  123. package/dist/src/composites/transfer.tsx +7 -0
  124. package/dist/src/composites/user-picker.tsx +7 -0
  125. package/dist/src/hooks/use-html-dark.ts +37 -0
  126. package/dist/src/hooks/use-mobile.ts +3 -0
  127. package/dist/src/i18n/en.ts +109 -2
  128. package/dist/src/i18n/zh.ts +99 -2
  129. package/dist/src/index.ts +147 -134
  130. package/dist/src/lib/clipboard.ts +10 -0
  131. package/dist/src/lib/illustrations.tsx +2 -2
  132. package/dist/src/products/code-block.tsx +35 -18
  133. package/dist/src/products/code-editor.tsx +156 -24
  134. package/dist/src/products/commit-graph.tsx +644 -0
  135. package/dist/src/products/data-grid.tsx +2 -1
  136. package/dist/src/products/diff-viewer.tsx +104 -8
  137. package/dist/src/products/event-calendar.tsx +28 -823
  138. package/dist/src/products/file-dropzone.tsx +7 -0
  139. package/dist/src/products/full-calendar/animations.ts +38 -0
  140. package/dist/src/products/full-calendar/calendar-body.tsx +77 -0
  141. package/dist/src/products/full-calendar/constants.ts +10 -0
  142. package/dist/src/products/full-calendar/contexts/calendar-context.tsx +255 -0
  143. package/dist/src/products/full-calendar/contexts/dnd-context.tsx +157 -0
  144. package/dist/src/products/full-calendar/dialogs/add-edit-event-dialog.tsx +187 -0
  145. package/dist/src/products/full-calendar/dialogs/delete-event-dialog.tsx +39 -0
  146. package/dist/src/products/full-calendar/dialogs/event-details-dialog.tsx +100 -0
  147. package/dist/src/products/full-calendar/dialogs/events-list-dialog.tsx +87 -0
  148. package/dist/src/products/full-calendar/dnd/draggable-event.tsx +45 -0
  149. package/dist/src/products/full-calendar/dnd/droppable-area.tsx +45 -0
  150. package/dist/src/products/full-calendar/dnd/resizable-event.tsx +179 -0
  151. package/dist/src/products/full-calendar/header/calendar-header.tsx +65 -0
  152. package/dist/src/products/full-calendar/header/date-navigator.tsx +110 -0
  153. package/dist/src/products/full-calendar/header/filter.tsx +58 -0
  154. package/dist/src/products/full-calendar/header/today-button.tsx +45 -0
  155. package/dist/src/products/full-calendar/header/user-select.tsx +64 -0
  156. package/dist/src/products/full-calendar/header/view-tabs.tsx +36 -0
  157. package/dist/src/products/full-calendar/helpers.ts +453 -0
  158. package/dist/src/products/full-calendar/hooks/use-time-range-create.ts +152 -0
  159. package/dist/src/products/full-calendar/hooks.ts +70 -0
  160. package/dist/src/products/full-calendar/interfaces.ts +23 -0
  161. package/dist/src/products/full-calendar/settings/settings.tsx +80 -0
  162. package/dist/src/products/full-calendar/shims/avatar-group.tsx +16 -0
  163. package/dist/src/products/full-calendar/shims/day-picker.tsx +1 -0
  164. package/dist/src/products/full-calendar/shims/motion.tsx +97 -0
  165. package/dist/src/products/full-calendar/shims/next-themes.ts +6 -0
  166. package/dist/src/products/full-calendar/shims/re-resizable.tsx +25 -0
  167. package/dist/src/products/full-calendar/shims/sonner.ts +4 -0
  168. package/dist/src/products/full-calendar/types.ts +8 -0
  169. package/dist/src/products/full-calendar/views/agenda-view/agenda-events.tsx +140 -0
  170. package/dist/src/products/full-calendar/views/month-view/calendar-month-view.tsx +69 -0
  171. package/dist/src/products/full-calendar/views/month-view/day-cell.tsx +201 -0
  172. package/dist/src/products/full-calendar/views/month-view/event-bullet.tsx +40 -0
  173. package/dist/src/products/full-calendar/views/month-view/month-event-badge.tsx +143 -0
  174. package/dist/src/products/full-calendar/views/week-and-day-view/calendar-day-view.tsx +295 -0
  175. package/dist/src/products/full-calendar/views/week-and-day-view/calendar-time-line.tsx +35 -0
  176. package/dist/src/products/full-calendar/views/week-and-day-view/calendar-week-view.tsx +253 -0
  177. package/dist/src/products/full-calendar/views/week-and-day-view/day-view-multi-day-events-row.tsx +73 -0
  178. package/dist/src/products/full-calendar/views/week-and-day-view/event-block.tsx +114 -0
  179. package/dist/src/products/full-calendar/views/week-and-day-view/render-grouped-events.tsx +49 -0
  180. package/dist/src/products/full-calendar/views/week-and-day-view/week-view-multi-day-events-row.tsx +143 -0
  181. package/dist/src/products/full-calendar/views/year-view/calendar-year-view.tsx +161 -0
  182. package/dist/src/products/gantt.tsx +7 -0
  183. package/dist/src/products/jira-wiki.tsx +1 -0
  184. package/dist/src/products/jql-input.tsx +340 -37
  185. package/dist/src/products/json-viewer.tsx +7 -0
  186. package/dist/src/products/kanban-issue-panel.tsx +7 -0
  187. package/dist/src/products/kanban.tsx +1 -0
  188. package/dist/src/products/log-viewer.tsx +390 -18
  189. package/dist/src/products/markdown-editor.tsx +21 -6
  190. package/dist/src/products/markdown.tsx +7 -0
  191. package/dist/src/products/rich-text-editor.tsx +65 -59
  192. package/dist/src/products/sortable-list.tsx +7 -0
  193. package/dist/src/products/stepper.tsx +219 -70
  194. package/dist/src/products/timeline.tsx +7 -0
  195. package/dist/src/products/tree-view.tsx +7 -0
  196. package/dist/src/sdk/app-error-boundary.tsx +240 -0
  197. package/dist/src/sdk/app-id.ts +7 -0
  198. package/dist/src/sdk/use-app.ts +126 -0
  199. package/dist/src/styles/globals.css +1 -0
  200. package/dist/vendors/lodash.js +20 -0
  201. package/dist/vendors/motion.js +12 -0
  202. package/package.json +20 -19
  203. package/dist/src/products/jql-language.ts +0 -70
@@ -1,33 +1,405 @@
1
- "use client"
1
+ "use client";
2
2
 
3
- import { useVirtualizer } from "@tanstack/react-virtual"
4
- import * as React from "react"
3
+ import * as React from "react";
4
+ import { useVirtualizer } from "@tanstack/react-virtual";
5
+ import {
6
+ ArrowDown,
7
+ Check,
8
+ Copy,
9
+ Download,
10
+ Pause,
11
+ Play,
12
+ Search,
13
+ Terminal,
14
+ Trash2,
15
+ X,
16
+ } from "lucide-react";
17
+
18
+ import { useLabels } from "@monkey-mini-app/ui/i18n/context";
19
+ import { writeClipboard } from "@monkey-mini-app/ui/lib/clipboard";
20
+ import { cn } from "@monkey-mini-app/ui/lib/utils";
21
+
22
+ export type LogLevel = "info" | "warn" | "error" | "debug" | "verbose";
23
+
24
+ export type LogEntry = {
25
+ level: LogLevel;
26
+ message: string;
27
+ timestamp?: string;
28
+ };
29
+
30
+ type LevelColors = {
31
+ text: string;
32
+ };
33
+
34
+ const DEFAULT_LEVEL_COLORS: Record<LogLevel, LevelColors> = {
35
+ error: { text: "text-rose-500 dark:text-rose-400" },
36
+ warn: { text: "text-amber-500 dark:text-amber-400" },
37
+ info: { text: "text-sky-500 dark:text-sky-400" },
38
+ debug: { text: "text-violet-500 dark:text-violet-400" },
39
+ verbose: { text: "text-zinc-400 dark:text-zinc-500" },
40
+ };
41
+
42
+ const LEVEL_LABELS: Record<LogLevel, string> = {
43
+ error: "ERR",
44
+ warn: "WRN",
45
+ info: "INF",
46
+ debug: "DBG",
47
+ verbose: "VRB",
48
+ };
49
+
50
+ function formatTimestampFull(ts?: string): string {
51
+ const d = ts ? new Date(ts) : new Date();
52
+ const ms = d.getMilliseconds().toString().padStart(3, "0");
53
+ return `${d.toLocaleTimeString("en-US", {
54
+ hour12: false,
55
+ hour: "2-digit",
56
+ minute: "2-digit",
57
+ second: "2-digit",
58
+ })}.${ms}`;
59
+ }
60
+
61
+ function useCopy() {
62
+ const [copied, setCopied] = React.useState(false);
63
+
64
+ const copy = React.useCallback(async (value: string) => {
65
+ if (!(await writeClipboard(value))) return;
66
+ setCopied(true);
67
+ window.setTimeout(() => setCopied(false), 1500);
68
+ }, []);
69
+
70
+ return { copied, copy };
71
+ }
72
+
73
+ function exportLogs(entries: LogEntry[]): void {
74
+ const text = entries
75
+ .map(
76
+ (e) =>
77
+ `[${formatTimestampFull(e.timestamp)}] [${LEVEL_LABELS[e.level]}] ${e.message}`,
78
+ )
79
+ .join("\n");
80
+ const blob = new Blob([text], { type: "text/plain" });
81
+ const url = URL.createObjectURL(blob);
82
+ const a = document.createElement("a");
83
+ a.href = url;
84
+ a.download = `logs-${new Date().toISOString().slice(0, 19).replace(/:/g, "-")}.txt`;
85
+ a.click();
86
+ URL.revokeObjectURL(url);
87
+ }
88
+
89
+ function highlightSearch(text: string, query: string): React.ReactNode {
90
+ if (!query) return text;
91
+ const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
92
+ const re = new RegExp(escaped, "gi");
93
+ const nodes: React.ReactNode[] = [];
94
+ let lastIndex = 0;
95
+ let match = re.exec(text);
96
+ while (match) {
97
+ if (match.index > lastIndex) {
98
+ nodes.push(text.slice(lastIndex, match.index));
99
+ }
100
+ nodes.push(
101
+ <mark
102
+ key={`h-${match.index}-${match[0]}`}
103
+ className="rounded-sm bg-amber-300/40 px-0.5 text-inherit dark:bg-amber-400/30"
104
+ >
105
+ {match[0]}
106
+ </mark>,
107
+ );
108
+ lastIndex = match.index + match[0].length;
109
+ match = re.exec(text);
110
+ }
111
+ if (lastIndex < text.length) {
112
+ nodes.push(text.slice(lastIndex));
113
+ }
114
+ return nodes;
115
+ }
116
+
117
+ function ToolbarButton({
118
+ onClick,
119
+ label,
120
+ active,
121
+ children,
122
+ className,
123
+ }: {
124
+ onClick: () => void;
125
+ label: string;
126
+ active?: boolean;
127
+ children: React.ReactNode;
128
+ className?: string;
129
+ }) {
130
+ return (
131
+ <button
132
+ type="button"
133
+ onClick={onClick}
134
+ aria-label={label}
135
+ className={cn(
136
+ "inline-flex size-7 items-center justify-center rounded-md transition-colors outline-none",
137
+ "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
138
+ "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
139
+ active && "bg-accent text-accent-foreground",
140
+ className,
141
+ )}
142
+ >
143
+ {children}
144
+ </button>
145
+ );
146
+ }
147
+
148
+ /**
149
+ * Virtualized streaming log viewer with level colors, search, and auto-scroll.
150
+ * @when Streaming / CLI-style log output
151
+ * @example
152
+ * <LogViewer entries={[{ level: "info", message: "boot" }, { level: "error", message: "boom" }]} />
153
+ * @family Realtime
154
+ */
155
+ export function LogViewer({
156
+ entries,
157
+ title,
158
+ maxHeight = 320,
159
+ lineNumbers = true,
160
+ timestamps = true,
161
+ autoScroll = true,
162
+ onClear,
163
+ className,
164
+ }: {
165
+ entries: LogEntry[];
166
+ title?: string;
167
+ maxHeight?: number;
168
+ lineNumbers?: boolean;
169
+ timestamps?: boolean;
170
+ autoScroll?: boolean;
171
+ onClear?: () => void;
172
+ className?: string;
173
+ }) {
174
+ const t = useLabels("logViewer");
175
+ const resolvedTitle = title ?? t.title;
176
+ const [paused, setPaused] = React.useState(false);
177
+ const [searchOpen, setSearchOpen] = React.useState(false);
178
+ const [searchQuery, setSearchQuery] = React.useState("");
179
+ const [isAtBottom, setIsAtBottom] = React.useState(true);
180
+ const parentRef = React.useRef<HTMLDivElement>(null);
181
+ const { copied, copy } = useCopy();
182
+
183
+ const filteredEntries = React.useMemo(
184
+ () =>
185
+ searchQuery
186
+ ? entries.filter((e) =>
187
+ e.message.toLowerCase().includes(searchQuery.toLowerCase()),
188
+ )
189
+ : entries,
190
+ [entries, searchQuery],
191
+ );
5
192
 
6
- export function LogViewer({ lines }: { lines: string[] }) {
7
- const parentRef = React.useRef<HTMLDivElement>(null)
8
193
  const virtualizer = useVirtualizer({
9
- count: lines.length,
194
+ count: filteredEntries.length,
10
195
  getScrollElement: () => parentRef.current,
11
- estimateSize: () => 22,
196
+ estimateSize: () => 24,
12
197
  overscan: 12,
13
- })
198
+ });
199
+
200
+ const scrollEnabled = autoScroll && !paused;
201
+
202
+ React.useEffect(() => {
203
+ if (!scrollEnabled || !isAtBottom || filteredEntries.length === 0) return;
204
+ virtualizer.scrollToIndex(filteredEntries.length - 1, { align: "end" });
205
+ }, [filteredEntries.length, scrollEnabled, isAtBottom, virtualizer]);
206
+
207
+ const handleScroll = React.useCallback(() => {
208
+ const el = parentRef.current;
209
+ if (!el) return;
210
+ const threshold = 40;
211
+ const atBottom =
212
+ el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
213
+ setIsAtBottom(atBottom);
214
+ }, []);
215
+
216
+ const scrollToBottom = React.useCallback(() => {
217
+ if (filteredEntries.length === 0) return;
218
+ virtualizer.scrollToIndex(filteredEntries.length - 1, { align: "end" });
219
+ setIsAtBottom(true);
220
+ }, [filteredEntries.length, virtualizer]);
221
+
222
+ const lineNumberWidth = Math.max(String(filteredEntries.length).length, 3);
223
+
224
+ function handleCopyAll() {
225
+ const text = entries
226
+ .map(
227
+ (e) =>
228
+ `[${formatTimestampFull(e.timestamp)}] [${LEVEL_LABELS[e.level]}] ${e.message}`,
229
+ )
230
+ .join("\n");
231
+ void copy(text);
232
+ }
233
+
14
234
  return (
15
235
  <div
16
- ref={parentRef}
17
236
  data-testid="log-viewer"
18
- className="h-64 overflow-auto rounded-xl border bg-card font-mono text-xs"
237
+ className={cn(
238
+ "flex flex-col overflow-hidden rounded-xl border border-border/60 bg-card shadow-sm",
239
+ className,
240
+ )}
19
241
  >
20
- <div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
21
- {virtualizer.getVirtualItems().map((item) => (
242
+ <div className="flex items-center gap-2 border-b border-border/40 bg-muted/30 px-3 py-2">
243
+ <Terminal className="size-3.5 shrink-0 text-muted-foreground" />
244
+ <span className="flex-1 truncate text-sm font-medium text-foreground">
245
+ {resolvedTitle}
246
+ </span>
247
+
248
+ <span className="mr-1 text-[10px] tabular-nums text-muted-foreground">
249
+ {searchQuery
250
+ ? t.linesFiltered(filteredEntries.length, entries.length)
251
+ : t.lines(filteredEntries.length)}
252
+ </span>
253
+
254
+ <div className="flex items-center gap-0.5">
255
+ <ToolbarButton
256
+ onClick={() => {
257
+ setSearchOpen(!searchOpen);
258
+ if (searchOpen) setSearchQuery("");
259
+ }}
260
+ label={searchOpen ? t.closeSearch : t.search}
261
+ active={searchOpen}
262
+ >
263
+ <Search className="size-3.5" />
264
+ </ToolbarButton>
265
+
266
+ <ToolbarButton
267
+ onClick={() => setPaused(!paused)}
268
+ label={paused ? t.resumeScroll : t.pauseScroll}
269
+ active={paused}
270
+ >
271
+ {paused ? (
272
+ <Play className="size-3.5" />
273
+ ) : (
274
+ <Pause className="size-3.5" />
275
+ )}
276
+ </ToolbarButton>
277
+
278
+ <ToolbarButton
279
+ onClick={handleCopyAll}
280
+ label={copied ? t.copied : t.copyAll}
281
+ >
282
+ {copied ? (
283
+ <Check className="size-3.5 text-emerald-500" />
284
+ ) : (
285
+ <Copy className="size-3.5" />
286
+ )}
287
+ </ToolbarButton>
288
+
289
+ <ToolbarButton onClick={() => exportLogs(entries)} label={t.download}>
290
+ <Download className="size-3.5" />
291
+ </ToolbarButton>
292
+
293
+ {onClear ? (
294
+ <ToolbarButton onClick={onClear} label={t.clear}>
295
+ <Trash2 className="size-3.5" />
296
+ </ToolbarButton>
297
+ ) : null}
298
+ </div>
299
+ </div>
300
+
301
+ {searchOpen ? (
302
+ <div className="flex items-center gap-2 border-b border-border/40 bg-muted/20 px-3 py-1.5">
303
+ <Search className="size-3.5 shrink-0 text-muted-foreground" />
304
+ <input
305
+ type="text"
306
+ value={searchQuery}
307
+ onChange={(e) => setSearchQuery(e.target.value)}
308
+ placeholder={t.filterPlaceholder}
309
+ className="flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
310
+ />
311
+ {searchQuery ? (
312
+ <button
313
+ type="button"
314
+ onClick={() => setSearchQuery("")}
315
+ className="inline-flex size-5 items-center justify-center rounded text-muted-foreground hover:text-foreground"
316
+ aria-label={t.clearSearch}
317
+ >
318
+ <X className="size-3" />
319
+ </button>
320
+ ) : null}
321
+ </div>
322
+ ) : null}
323
+
324
+ <div
325
+ ref={parentRef}
326
+ onScroll={handleScroll}
327
+ className="overflow-auto bg-card font-mono text-xs leading-relaxed [scrollbar-width:thin]"
328
+ style={{ maxHeight }}
329
+ role="log"
330
+ aria-live="polite"
331
+ aria-label={resolvedTitle}
332
+ >
333
+ {filteredEntries.length === 0 ? (
334
+ <div className="flex items-center justify-center py-10 text-sm text-muted-foreground">
335
+ {searchQuery ? t.emptySearch : t.empty}
336
+ </div>
337
+ ) : (
22
338
  <div
23
- key={item.key}
24
- className="absolute left-0 w-full truncate px-3"
25
- style={{ top: item.start, height: item.size }}
339
+ style={{
340
+ height: virtualizer.getTotalSize(),
341
+ position: "relative",
342
+ width: "100%",
343
+ }}
26
344
  >
27
- {lines[item.index]}
345
+ {virtualizer.getVirtualItems().map((item) => {
346
+ const entry = filteredEntries[item.index];
347
+ if (!entry) return null;
348
+ const colors = DEFAULT_LEVEL_COLORS[entry.level];
349
+ return (
350
+ <div
351
+ key={item.key}
352
+ data-index={item.index}
353
+ ref={virtualizer.measureElement}
354
+ className="absolute left-0 flex w-full gap-3 border-b border-border/20 px-3 py-1 transition-colors hover:bg-muted/30"
355
+ style={{
356
+ top: item.start,
357
+ height: item.size,
358
+ }}
359
+ >
360
+ {lineNumbers ? (
361
+ <span
362
+ className="shrink-0 select-none text-right text-muted-foreground/50"
363
+ style={{ width: `${lineNumberWidth}ch` }}
364
+ aria-hidden="true"
365
+ >
366
+ {item.index + 1}
367
+ </span>
368
+ ) : null}
369
+ {timestamps ? (
370
+ <span className="shrink-0 text-muted-foreground/60">
371
+ {formatTimestampFull(entry.timestamp)}
372
+ </span>
373
+ ) : null}
374
+ <span
375
+ className={cn(
376
+ "w-[3ch] shrink-0 text-right font-semibold",
377
+ colors.text,
378
+ )}
379
+ >
380
+ {LEVEL_LABELS[entry.level]}
381
+ </span>
382
+ <span className="min-w-0 flex-1 truncate whitespace-pre text-foreground/90">
383
+ {highlightSearch(entry.message, searchQuery)}
384
+ </span>
385
+ </div>
386
+ );
387
+ })}
28
388
  </div>
29
- ))}
389
+ )}
30
390
  </div>
391
+
392
+ {!isAtBottom ? (
393
+ <button
394
+ type="button"
395
+ onClick={scrollToBottom}
396
+ className="flex w-full items-center justify-center gap-1.5 border-t border-border/40 bg-muted/30 py-1.5 text-[10px] font-medium text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
397
+ aria-label={t.scrollLatest}
398
+ >
399
+ <ArrowDown className="size-3" />
400
+ {t.newBelow}
401
+ </button>
402
+ ) : null}
31
403
  </div>
32
- )
404
+ );
33
405
  }
@@ -1,5 +1,7 @@
1
1
  "use client"
2
2
 
3
+ import * as React from "react"
4
+
3
5
  import { Button } from "@monkey-mini-app/ui/components/button"
4
6
  import { CodeEditor } from "@monkey-mini-app/ui/products/code-editor"
5
7
  import { Markdown } from "@monkey-mini-app/ui/products/markdown"
@@ -8,6 +10,13 @@ import { useLabels } from "@monkey-mini-app/ui/i18n/context"
8
10
 
9
11
  export type MarkdownEditorMode = "edit" | "split" | "preview"
10
12
 
13
+ /**
14
+ * Markdown edit + preview toggle.
15
+ * @when Authoring md bodies. Read-only render → `Markdown`.
16
+ * @example
17
+ * <MarkdownEditor value={md} onChange={(v) => set(v)} />
18
+ * @family Rich text
19
+ */
11
20
  export function MarkdownEditor({
12
21
  value,
13
22
  onChange,
@@ -20,6 +29,12 @@ export function MarkdownEditor({
20
29
  onModeChange?: (mode: MarkdownEditorMode) => void
21
30
  }) {
22
31
  const t = useLabels("markdownEditor")
32
+ const [currentMode, setCurrentMode] = React.useState<MarkdownEditorMode>(mode)
33
+ React.useEffect(() => setCurrentMode(mode), [mode])
34
+ const setMode = (next: MarkdownEditorMode) => {
35
+ setCurrentMode(next)
36
+ onModeChange?.(next)
37
+ }
23
38
  return (
24
39
  <div data-testid="markdown-editor" className="overflow-hidden rounded-xl border bg-card">
25
40
  <div className="flex items-center gap-1 border-b p-1">
@@ -27,9 +42,9 @@ export function MarkdownEditor({
27
42
  <Button
28
43
  key={item}
29
44
  size="sm"
30
- variant={mode === item ? "default" : "ghost"}
45
+ variant={currentMode === item ? "default" : "ghost"}
31
46
  data-testid={`markdown-mode-${item}`}
32
- onClick={() => onModeChange?.(item)}
47
+ onClick={() => setMode(item)}
33
48
  >
34
49
  {t[item]}
35
50
  </Button>
@@ -38,11 +53,11 @@ export function MarkdownEditor({
38
53
  <div
39
54
  className={cn(
40
55
  "grid min-h-[280px]",
41
- mode === "split" ? "md:grid-cols-2" : "grid-cols-1"
56
+ currentMode === "split" ? "md:grid-cols-2" : "grid-cols-1"
42
57
  )}
43
58
  >
44
- {mode !== "preview" ? (
45
- <div className={cn(mode === "split" && "border-b md:border-r md:border-b-0")}>
59
+ {currentMode !== "preview" ? (
60
+ <div className={cn(currentMode === "split" && "border-b md:border-r md:border-b-0")}>
46
61
  <CodeEditor
47
62
  value={value}
48
63
  onChange={onChange}
@@ -52,7 +67,7 @@ export function MarkdownEditor({
52
67
  />
53
68
  </div>
54
69
  ) : null}
55
- {mode !== "edit" ? (
70
+ {currentMode !== "edit" ? (
56
71
  <div className="overflow-auto p-3" data-testid="markdown-preview">
57
72
  <Markdown>{value}</Markdown>
58
73
  </div>
@@ -5,6 +5,13 @@ import remarkGfm from "remark-gfm"
5
5
 
6
6
  import { cn } from "@monkey-mini-app/ui/lib/utils"
7
7
 
8
+ /**
9
+ * Render a markdown string.
10
+ * @when Model output, README-ish bodies. Children must be a string.
11
+ * @example
12
+ * <Markdown>{text}</Markdown>
13
+ * @family Rich text
14
+ */
8
15
  export function Markdown({
9
16
  children,
10
17
  className,
@@ -1,14 +1,24 @@
1
1
  "use client"
2
2
 
3
- import { useEditor, EditorContent } from "@tiptap/react"
4
- import StarterKit from "@tiptap/starter-kit"
5
- import Placeholder from "@tiptap/extension-placeholder"
3
+ import * as React from "react"
6
4
  import { Bold, Code, Italic, List, ListOrdered } from "lucide-react"
7
5
 
8
- import { Toggle } from "@monkey-mini-app/ui/components/toggle"
9
- import { cn } from "@monkey-mini-app/ui/lib/utils"
6
+ import { Button } from "@monkey-mini-app/ui/components/button"
10
7
  import { useLabels } from "@monkey-mini-app/ui/i18n/context"
8
+ import { cn } from "@monkey-mini-app/ui/lib/utils"
9
+
10
+ function runCommand(command: string, value?: string) {
11
+ // Same primitive as react-simple-wysiwyg. Deprecated, still what lightweight
12
+ // browsers expose for simple WYSIWYG; no TipTap / CDN / second React.
13
+ document.execCommand(command, false, value)
14
+ }
11
15
 
16
+ /**
17
+ * Lightweight rich-text editor (contentEditable + toolbar), same idea as
18
+ * `react-simple-wysiwyg`. HTML is browser-generated — sanitize server-side if needed.
19
+ * @family Rich text
20
+ * @when Notes/descriptions you store as **HTML**. Markdown → `MarkdownEditor`; code → `CodeEditor`. Sanitize server-side before rendering elsewhere.
21
+ */
12
22
  export function RichTextEditor({
13
23
  value = "<p></p>",
14
24
  onChange,
@@ -19,66 +29,62 @@ export function RichTextEditor({
19
29
  placeholder?: string
20
30
  }) {
21
31
  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
- })
32
+ const ref = React.useRef<HTMLDivElement>(null)
33
+ const ph = placeholder ?? t.placeholder
34
+
35
+ React.useEffect(() => {
36
+ const el = ref.current
37
+ if (!el) return
38
+ if (el.innerHTML !== value) el.innerHTML = value || ""
39
+ }, [value])
40
+
41
+ const emit = () => {
42
+ onChange?.(ref.current?.innerHTML ?? "")
43
+ }
34
44
 
35
- if (!editor) return <div data-testid="rich-text-editor" className="min-h-40 rounded-xl border" />
45
+ const cmd = (command: string, arg?: string) => (e: React.MouseEvent) => {
46
+ e.preventDefault()
47
+ ref.current?.focus()
48
+ runCommand(command, arg)
49
+ emit()
50
+ }
36
51
 
37
52
  return (
38
53
  <div data-testid="rich-text-editor" className="overflow-hidden rounded-xl border bg-card">
39
54
  <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>
55
+ <Button type="button" size="sm" variant="ghost" aria-label={t.bold} onMouseDown={cmd("bold")}>
56
+ <Bold className="size-3.5" />
57
+ </Button>
58
+ <Button type="button" size="sm" variant="ghost" aria-label={t.italic} onMouseDown={cmd("italic")}>
59
+ <Italic className="size-3.5" />
60
+ </Button>
61
+ <Button type="button" size="sm" variant="ghost" aria-label={t.code} onMouseDown={cmd("formatBlock", "pre")}>
62
+ <Code className="size-3.5" />
63
+ </Button>
64
+ <Button type="button" size="sm" variant="ghost" aria-label={t.bullet} onMouseDown={cmd("insertUnorderedList")}>
65
+ <List className="size-3.5" />
66
+ </Button>
67
+ <Button type="button" size="sm" variant="ghost" aria-label={t.ordered} onMouseDown={cmd("insertOrderedList")}>
68
+ <ListOrdered className="size-3.5" />
69
+ </Button>
80
70
  </div>
81
- <EditorContent editor={editor} className={cn("prose-sm max-w-none")} />
71
+ <div
72
+ ref={ref}
73
+ role="textbox"
74
+ aria-multiline="true"
75
+ aria-placeholder={ph}
76
+ contentEditable
77
+ suppressContentEditableWarning
78
+ data-placeholder={ph}
79
+ className={cn(
80
+ "min-h-40 px-3 py-2 text-sm outline-none",
81
+ "[&_ul]:list-disc [&_ul]:pl-6 [&_ol]:list-decimal [&_ol]:pl-6",
82
+ "[&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:px-2 [&_pre]:py-1 [&_pre]:font-mono [&_pre]:text-xs",
83
+ "empty:before:pointer-events-none empty:before:text-muted-foreground empty:before:content-[attr(data-placeholder)]",
84
+ )}
85
+ onInput={emit}
86
+ onBlur={emit}
87
+ />
82
88
  </div>
83
89
  )
84
90
  }
@@ -33,6 +33,13 @@ function Row({ item }: { item: SortableItem }) {
33
33
  )
34
34
  }
35
35
 
36
+ /**
37
+ * Drag-to-reorder list.
38
+ * @when Priority queues / column order. Read-only ordered events → `Timeline`.
39
+ * @example
40
+ * <SortableList items={items} onChange={(next) => set(next)} />
41
+ * @family Data & tables
42
+ */
36
43
  export function SortableList({
37
44
  items,
38
45
  onChange,