@modern-admin/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 (268) hide show
  1. package/dist/components/accordion.d.ts +7 -0
  2. package/dist/components/accordion.d.ts.map +1 -0
  3. package/dist/components/accordion.jsx +19 -0
  4. package/dist/components/accordion.jsx.map +1 -0
  5. package/dist/components/alert-dialog.d.ts +22 -0
  6. package/dist/components/alert-dialog.d.ts.map +1 -0
  7. package/dist/components/alert-dialog.jsx +27 -0
  8. package/dist/components/alert-dialog.jsx.map +1 -0
  9. package/dist/components/audit-timeline.d.ts +24 -0
  10. package/dist/components/audit-timeline.d.ts.map +1 -0
  11. package/dist/components/audit-timeline.jsx +60 -0
  12. package/dist/components/audit-timeline.jsx.map +1 -0
  13. package/dist/components/avatar.d.ts +6 -0
  14. package/dist/components/avatar.d.ts.map +1 -0
  15. package/dist/components/avatar.jsx +10 -0
  16. package/dist/components/avatar.jsx.map +1 -0
  17. package/dist/components/badge.d.ts +10 -0
  18. package/dist/components/badge.d.ts.map +1 -0
  19. package/dist/components/badge.jsx +19 -0
  20. package/dist/components/badge.jsx.map +1 -0
  21. package/dist/components/breadcrumb.d.ts +17 -0
  22. package/dist/components/breadcrumb.d.ts.map +1 -0
  23. package/dist/components/breadcrumb.jsx +27 -0
  24. package/dist/components/breadcrumb.jsx.map +1 -0
  25. package/dist/components/button.d.ts +12 -0
  26. package/dist/components/button.d.ts.map +1 -0
  27. package/dist/components/button.jsx +37 -0
  28. package/dist/components/button.jsx.map +1 -0
  29. package/dist/components/calendar.d.ts +9 -0
  30. package/dist/components/calendar.d.ts.map +1 -0
  31. package/dist/components/calendar.jsx +102 -0
  32. package/dist/components/calendar.jsx.map +1 -0
  33. package/dist/components/card.d.ts +8 -0
  34. package/dist/components/card.d.ts.map +1 -0
  35. package/dist/components/card.jsx +18 -0
  36. package/dist/components/card.jsx.map +1 -0
  37. package/dist/components/chart.d.ts +97 -0
  38. package/dist/components/chart.d.ts.map +1 -0
  39. package/dist/components/chart.jsx +233 -0
  40. package/dist/components/chart.jsx.map +1 -0
  41. package/dist/components/checkbox.d.ts +4 -0
  42. package/dist/components/checkbox.d.ts.map +1 -0
  43. package/dist/components/checkbox.jsx +11 -0
  44. package/dist/components/checkbox.jsx.map +1 -0
  45. package/dist/components/combobox.d.ts +46 -0
  46. package/dist/components/combobox.d.ts.map +1 -0
  47. package/dist/components/combobox.jsx +145 -0
  48. package/dist/components/combobox.jsx.map +1 -0
  49. package/dist/components/command.d.ts +80 -0
  50. package/dist/components/command.d.ts.map +1 -0
  51. package/dist/components/command.jsx +32 -0
  52. package/dist/components/command.jsx.map +1 -0
  53. package/dist/components/date-picker.d.ts +24 -0
  54. package/dist/components/date-picker.d.ts.map +1 -0
  55. package/dist/components/date-picker.jsx +149 -0
  56. package/dist/components/date-picker.jsx.map +1 -0
  57. package/dist/components/date-range-input.d.ts +22 -0
  58. package/dist/components/date-range-input.d.ts.map +1 -0
  59. package/dist/components/date-range-input.jsx +202 -0
  60. package/dist/components/date-range-input.jsx.map +1 -0
  61. package/dist/components/dialog.d.ts +19 -0
  62. package/dist/components/dialog.d.ts.map +1 -0
  63. package/dist/components/dialog.jsx +30 -0
  64. package/dist/components/dialog.jsx.map +1 -0
  65. package/dist/components/diff-view.d.ts +24 -0
  66. package/dist/components/diff-view.d.ts.map +1 -0
  67. package/dist/components/diff-view.jsx +69 -0
  68. package/dist/components/diff-view.jsx.map +1 -0
  69. package/dist/components/dropdown-menu.d.ts +27 -0
  70. package/dist/components/dropdown-menu.d.ts.map +1 -0
  71. package/dist/components/dropdown-menu.jsx +48 -0
  72. package/dist/components/dropdown-menu.jsx.map +1 -0
  73. package/dist/components/empty.d.ts +15 -0
  74. package/dist/components/empty.d.ts.map +1 -0
  75. package/dist/components/empty.jsx +27 -0
  76. package/dist/components/empty.jsx.map +1 -0
  77. package/dist/components/field.d.ts +23 -0
  78. package/dist/components/field.d.ts.map +1 -0
  79. package/dist/components/field.jsx +60 -0
  80. package/dist/components/field.jsx.map +1 -0
  81. package/dist/components/file-input.d.ts +50 -0
  82. package/dist/components/file-input.d.ts.map +1 -0
  83. package/dist/components/file-input.jsx +104 -0
  84. package/dist/components/file-input.jsx.map +1 -0
  85. package/dist/components/form.d.ts +20 -0
  86. package/dist/components/form.d.ts.map +1 -0
  87. package/dist/components/form.jsx +66 -0
  88. package/dist/components/form.jsx.map +1 -0
  89. package/dist/components/info-tooltip.d.ts +11 -0
  90. package/dist/components/info-tooltip.d.ts.map +1 -0
  91. package/dist/components/info-tooltip.jsx +17 -0
  92. package/dist/components/info-tooltip.jsx.map +1 -0
  93. package/dist/components/input.d.ts +13 -0
  94. package/dist/components/input.d.ts.map +1 -0
  95. package/dist/components/input.jsx +19 -0
  96. package/dist/components/input.jsx.map +1 -0
  97. package/dist/components/json-editor.d.ts +23 -0
  98. package/dist/components/json-editor.d.ts.map +1 -0
  99. package/dist/components/json-editor.jsx +143 -0
  100. package/dist/components/json-editor.jsx.map +1 -0
  101. package/dist/components/kbd.d.ts +15 -0
  102. package/dist/components/kbd.d.ts.map +1 -0
  103. package/dist/components/kbd.jsx +23 -0
  104. package/dist/components/kbd.jsx.map +1 -0
  105. package/dist/components/key-value-editor.d.ts +92 -0
  106. package/dist/components/key-value-editor.d.ts.map +1 -0
  107. package/dist/components/key-value-editor.jsx +187 -0
  108. package/dist/components/key-value-editor.jsx.map +1 -0
  109. package/dist/components/keyboard-shortcuts-help.d.ts +17 -0
  110. package/dist/components/keyboard-shortcuts-help.d.ts.map +1 -0
  111. package/dist/components/keyboard-shortcuts-help.jsx +97 -0
  112. package/dist/components/keyboard-shortcuts-help.jsx.map +1 -0
  113. package/dist/components/label.d.ts +5 -0
  114. package/dist/components/label.d.ts.map +1 -0
  115. package/dist/components/label.jsx +8 -0
  116. package/dist/components/label.jsx.map +1 -0
  117. package/dist/components/media-preview.d.ts +30 -0
  118. package/dist/components/media-preview.d.ts.map +1 -0
  119. package/dist/components/media-preview.jsx +189 -0
  120. package/dist/components/media-preview.jsx.map +1 -0
  121. package/dist/components/multi-file-input.d.ts +76 -0
  122. package/dist/components/multi-file-input.d.ts.map +1 -0
  123. package/dist/components/multi-file-input.jsx +131 -0
  124. package/dist/components/multi-file-input.jsx.map +1 -0
  125. package/dist/components/password-input.d.ts +10 -0
  126. package/dist/components/password-input.d.ts.map +1 -0
  127. package/dist/components/password-input.jsx +18 -0
  128. package/dist/components/password-input.jsx.map +1 -0
  129. package/dist/components/popover.d.ts +7 -0
  130. package/dist/components/popover.d.ts.map +1 -0
  131. package/dist/components/popover.jsx +11 -0
  132. package/dist/components/popover.jsx.map +1 -0
  133. package/dist/components/revision-timeline.d.ts +30 -0
  134. package/dist/components/revision-timeline.d.ts.map +1 -0
  135. package/dist/components/revision-timeline.jsx +42 -0
  136. package/dist/components/revision-timeline.jsx.map +1 -0
  137. package/dist/components/richtext-editor.d.ts +43 -0
  138. package/dist/components/richtext-editor.d.ts.map +1 -0
  139. package/dist/components/richtext-editor.jsx +319 -0
  140. package/dist/components/richtext-editor.jsx.map +1 -0
  141. package/dist/components/richtext-mode.d.ts +23 -0
  142. package/dist/components/richtext-mode.d.ts.map +1 -0
  143. package/dist/components/richtext-mode.js +36 -0
  144. package/dist/components/richtext-mode.js.map +1 -0
  145. package/dist/components/richtext-render.d.ts +8 -0
  146. package/dist/components/richtext-render.d.ts.map +1 -0
  147. package/dist/components/richtext-render.jsx +33 -0
  148. package/dist/components/richtext-render.jsx.map +1 -0
  149. package/dist/components/richtext-sync.d.ts +37 -0
  150. package/dist/components/richtext-sync.d.ts.map +1 -0
  151. package/dist/components/richtext-sync.js +46 -0
  152. package/dist/components/richtext-sync.js.map +1 -0
  153. package/dist/components/scroll-area.d.ts +5 -0
  154. package/dist/components/scroll-area.d.ts.map +1 -0
  155. package/dist/components/scroll-area.jsx +16 -0
  156. package/dist/components/scroll-area.jsx.map +1 -0
  157. package/dist/components/select.d.ts +36 -0
  158. package/dist/components/select.d.ts.map +1 -0
  159. package/dist/components/select.jsx +87 -0
  160. package/dist/components/select.jsx.map +1 -0
  161. package/dist/components/separator.d.ts +4 -0
  162. package/dist/components/separator.d.ts.map +1 -0
  163. package/dist/components/separator.jsx +6 -0
  164. package/dist/components/separator.jsx.map +1 -0
  165. package/dist/components/sheet.d.ts +29 -0
  166. package/dist/components/sheet.d.ts.map +1 -0
  167. package/dist/components/sheet.jsx +44 -0
  168. package/dist/components/sheet.jsx.map +1 -0
  169. package/dist/components/sidebar.d.ts +70 -0
  170. package/dist/components/sidebar.d.ts.map +1 -0
  171. package/dist/components/sidebar.jsx +245 -0
  172. package/dist/components/sidebar.jsx.map +1 -0
  173. package/dist/components/skeleton.d.ts +3 -0
  174. package/dist/components/skeleton.d.ts.map +1 -0
  175. package/dist/components/skeleton.jsx +6 -0
  176. package/dist/components/skeleton.jsx.map +1 -0
  177. package/dist/components/sonner.d.ts +6 -0
  178. package/dist/components/sonner.d.ts.map +1 -0
  179. package/dist/components/sonner.jsx +29 -0
  180. package/dist/components/sonner.jsx.map +1 -0
  181. package/dist/components/switch.d.ts +4 -0
  182. package/dist/components/switch.d.ts.map +1 -0
  183. package/dist/components/switch.jsx +8 -0
  184. package/dist/components/switch.jsx.map +1 -0
  185. package/dist/components/table.d.ts +10 -0
  186. package/dist/components/table.d.ts.map +1 -0
  187. package/dist/components/table.jsx +21 -0
  188. package/dist/components/table.jsx.map +1 -0
  189. package/dist/components/tabs.d.ts +7 -0
  190. package/dist/components/tabs.d.ts.map +1 -0
  191. package/dist/components/tabs.jsx +14 -0
  192. package/dist/components/tabs.jsx.map +1 -0
  193. package/dist/components/textarea.d.ts +4 -0
  194. package/dist/components/textarea.d.ts.map +1 -0
  195. package/dist/components/textarea.jsx +5 -0
  196. package/dist/components/textarea.jsx.map +1 -0
  197. package/dist/components/tooltip.d.ts +7 -0
  198. package/dist/components/tooltip.d.ts.map +1 -0
  199. package/dist/components/tooltip.jsx +11 -0
  200. package/dist/components/tooltip.jsx.map +1 -0
  201. package/dist/index.d.ts +52 -0
  202. package/dist/index.d.ts.map +1 -0
  203. package/dist/index.js +72 -0
  204. package/dist/index.js.map +1 -0
  205. package/dist/lib/theme.d.ts +11 -0
  206. package/dist/lib/theme.d.ts.map +1 -0
  207. package/dist/lib/theme.js +44 -0
  208. package/dist/lib/theme.js.map +1 -0
  209. package/dist/lib/utils.d.ts +3 -0
  210. package/dist/lib/utils.d.ts.map +1 -0
  211. package/dist/lib/utils.js +6 -0
  212. package/dist/lib/utils.js.map +1 -0
  213. package/dist/styles.css +242 -0
  214. package/package.json +85 -0
  215. package/src/components/accordion.tsx +48 -0
  216. package/src/components/alert-dialog.tsx +113 -0
  217. package/src/components/audit-timeline.tsx +102 -0
  218. package/src/components/avatar.tsx +42 -0
  219. package/src/components/badge.tsx +34 -0
  220. package/src/components/breadcrumb.tsx +99 -0
  221. package/src/components/button.tsx +58 -0
  222. package/src/components/calendar.tsx +176 -0
  223. package/src/components/card.tsx +60 -0
  224. package/src/components/chart.tsx +558 -0
  225. package/src/components/checkbox.tsx +23 -0
  226. package/src/components/combobox.tsx +264 -0
  227. package/src/components/command.tsx +120 -0
  228. package/src/components/date-picker.tsx +221 -0
  229. package/src/components/date-range-input.tsx +295 -0
  230. package/src/components/dialog.tsx +94 -0
  231. package/src/components/diff-view.tsx +182 -0
  232. package/src/components/dropdown-menu.tsx +165 -0
  233. package/src/components/empty.tsx +100 -0
  234. package/src/components/field.tsx +168 -0
  235. package/src/components/file-input.tsx +233 -0
  236. package/src/components/form.tsx +152 -0
  237. package/src/components/info-tooltip.tsx +40 -0
  238. package/src/components/input.tsx +55 -0
  239. package/src/components/json-editor.tsx +210 -0
  240. package/src/components/kbd.tsx +35 -0
  241. package/src/components/key-value-editor.tsx +423 -0
  242. package/src/components/keyboard-shortcuts-help.tsx +136 -0
  243. package/src/components/label.tsx +16 -0
  244. package/src/components/media-preview.tsx +278 -0
  245. package/src/components/multi-file-input.tsx +315 -0
  246. package/src/components/password-input.tsx +50 -0
  247. package/src/components/popover.tsx +26 -0
  248. package/src/components/revision-timeline.tsx +93 -0
  249. package/src/components/richtext-editor.tsx +624 -0
  250. package/src/components/richtext-mode.ts +39 -0
  251. package/src/components/richtext-render.tsx +51 -0
  252. package/src/components/richtext-sync.ts +57 -0
  253. package/src/components/scroll-area.tsx +41 -0
  254. package/src/components/select.tsx +200 -0
  255. package/src/components/separator.tsx +21 -0
  256. package/src/components/sheet.tsx +109 -0
  257. package/src/components/sidebar.tsx +660 -0
  258. package/src/components/skeleton.tsx +9 -0
  259. package/src/components/sonner.tsx +45 -0
  260. package/src/components/switch.tsx +24 -0
  261. package/src/components/table.tsx +93 -0
  262. package/src/components/tabs.tsx +57 -0
  263. package/src/components/textarea.tsx +18 -0
  264. package/src/components/tooltip.tsx +25 -0
  265. package/src/index.ts +342 -0
  266. package/src/lib/theme.ts +45 -0
  267. package/src/lib/utils.ts +6 -0
  268. package/src/styles.css +242 -0
@@ -0,0 +1,278 @@
1
+ // MediaPreview — opens a dialog with an image/video preview and a download
2
+ // button. Used by the `previewMedia` property type to render HTTP(S) URLs to
3
+ // remote photos/videos as a "Preview" button instead of raw text.
4
+
5
+ import * as React from 'react'
6
+ import { Download, Eye, ExternalLink, Loader2, AlertCircle } from 'lucide-react'
7
+ import { cn } from '../lib/utils.js'
8
+ import { Button } from './button.js'
9
+ import {
10
+ Dialog,
11
+ DialogContent,
12
+ DialogDescription,
13
+ DialogFooter,
14
+ DialogHeader,
15
+ DialogTitle,
16
+ } from './dialog.js'
17
+
18
+ export type MediaKind = 'image' | 'video' | 'audio' | 'unknown'
19
+
20
+ const IMAGE_EXT = /\.(jpe?g|png|gif|webp|avif|bmp|svg|ico|heic|heif)(\?|#|$)/i
21
+ const VIDEO_EXT = /\.(mp4|webm|mov|m4v|ogv|avi|mkv)(\?|#|$)/i
22
+ const AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac|flac)(\?|#|$)/i
23
+
24
+ export function detectMediaKind(url: string): MediaKind {
25
+ if (!url) return 'unknown'
26
+ if (IMAGE_EXT.test(url)) return 'image'
27
+ if (VIDEO_EXT.test(url)) return 'video'
28
+ if (AUDIO_EXT.test(url)) return 'audio'
29
+ return 'unknown'
30
+ }
31
+
32
+ /**
33
+ * Probes the Content-Type of `url` when extension-based detection returns
34
+ * 'unknown'. Runs only when `enabled` is true (dialog has been opened).
35
+ *
36
+ * Strategy:
37
+ * 1. HEAD request — reads Content-Type header (works for same-origin or
38
+ * CORS-enabled APIs like public avatar services).
39
+ * 2. Image constructor fallback — handles servers that block HEAD but allow
40
+ * img GET (bypasses CORS for image fetches by design).
41
+ *
42
+ * Returns [resolvedKind, isLoading].
43
+ */
44
+ function useProbeMediaKind(
45
+ url: string,
46
+ kindOverride: MediaKind | undefined,
47
+ enabled: boolean,
48
+ ): [MediaKind, boolean] {
49
+ const byExt = React.useMemo(() => detectMediaKind(url), [url])
50
+ const [kind, setKind] = React.useState<MediaKind>(kindOverride ?? byExt)
51
+ const [loading, setLoading] = React.useState(false)
52
+
53
+ // Keep kind in sync when url or kindOverride changes (e.g. parent re-renders).
54
+ React.useEffect(() => {
55
+ setKind(kindOverride ?? detectMediaKind(url))
56
+ setLoading(false)
57
+ }, [url, kindOverride])
58
+
59
+ React.useEffect(() => {
60
+ if (!enabled) return
61
+ if (kindOverride) return
62
+ if (byExt !== 'unknown') return
63
+ if (!url.startsWith('http')) return
64
+
65
+ let cancelled = false
66
+ const controller = new AbortController()
67
+ setLoading(true)
68
+
69
+ const resolve = (k: MediaKind) => {
70
+ if (cancelled) return
71
+ setKind(k)
72
+ setLoading(false)
73
+ }
74
+
75
+ // Image constructor fallback — works even when HEAD is CORS-blocked.
76
+ const tryImage = () => {
77
+ const img = new window.Image()
78
+ img.onload = () => resolve('image')
79
+ img.onerror = () => resolve('unknown')
80
+ img.src = url
81
+ }
82
+
83
+ fetch(url, { method: 'HEAD', signal: controller.signal })
84
+ .then((res) => {
85
+ const ct = res.headers.get('content-type') ?? ''
86
+ if (ct.startsWith('image/')) resolve('image')
87
+ else if (ct.startsWith('video/')) resolve('video')
88
+ else if (ct.startsWith('audio/')) resolve('audio')
89
+ else tryImage()
90
+ })
91
+ .catch(() => {
92
+ if (!cancelled) tryImage()
93
+ })
94
+
95
+ return () => {
96
+ cancelled = true
97
+ controller.abort()
98
+ }
99
+ }, [url, kindOverride, byExt, enabled])
100
+
101
+ return [kind, loading]
102
+ }
103
+
104
+ export interface MediaPreviewProps {
105
+ /** Media URL. Should be a fully-qualified HTTP(S) URL. */
106
+ url: string
107
+ /** Optional override for the auto-detected media kind. */
108
+ kind?: MediaKind
109
+ /** Suggested file name for the download. Falls back to URL pathname. */
110
+ downloadName?: string
111
+ /** Strings (already-translated) to render in the UI. */
112
+ labels?: {
113
+ preview?: string
114
+ download?: string
115
+ downloadError?: string
116
+ openInNewTab?: string
117
+ title?: string
118
+ description?: string
119
+ cannotPreview?: string
120
+ }
121
+ /** Show the URL as secondary text next to the trigger. */
122
+ showUrl?: boolean
123
+ /** Variant of the trigger button. Defaults to 'outline'. */
124
+ triggerVariant?: 'default' | 'outline' | 'secondary' | 'ghost' | 'link'
125
+ /** Size of the trigger button. Defaults to 'sm'. */
126
+ triggerSize?: 'default' | 'sm' | 'lg' | 'icon'
127
+ className?: string
128
+ }
129
+
130
+ export function MediaPreview({
131
+ url,
132
+ kind: kindProp,
133
+ downloadName,
134
+ labels,
135
+ showUrl = false,
136
+ triggerVariant = 'outline',
137
+ triggerSize = 'sm',
138
+ className,
139
+ }: MediaPreviewProps): React.ReactElement {
140
+ const [open, setOpen] = React.useState(false)
141
+ const [kind, kindLoading] = useProbeMediaKind(url, kindProp, open)
142
+ const [downloading, setDownloading] = React.useState(false)
143
+ const [downloadError, setDownloadError] = React.useState(false)
144
+
145
+ const previewLabel = labels?.preview ?? 'Preview'
146
+ const downloadLabel = labels?.download ?? 'Download'
147
+ const openLabel = labels?.openInNewTab ?? 'Open in new tab'
148
+ const titleLabel = labels?.title ?? previewLabel
149
+ const cannotLabel = labels?.cannotPreview ?? 'Preview is unavailable for this media type.'
150
+
151
+ const inferredName = React.useMemo(() => {
152
+ if (downloadName) return downloadName
153
+ try {
154
+ const u = new URL(url)
155
+ const last = u.pathname.split('/').filter(Boolean).pop()
156
+ return last || 'download'
157
+ } catch {
158
+ return 'download'
159
+ }
160
+ }, [downloadName, url])
161
+
162
+ // Force a file download via fetch → blob → blob URL. The <a download> attribute
163
+ // is silently ignored by browsers for cross-origin URLs, so we always use the
164
+ // blob approach. Falls back to window.open if fetch fails (e.g. no CORS).
165
+ const handleDownload = async () => {
166
+ setDownloadError(false)
167
+ setDownloading(true)
168
+ try {
169
+ const res = await fetch(url)
170
+ const blob = await res.blob()
171
+ const blobUrl = URL.createObjectURL(blob)
172
+ const a = document.createElement('a')
173
+ a.href = blobUrl
174
+ a.download = inferredName
175
+ document.body.appendChild(a)
176
+ a.click()
177
+ document.body.removeChild(a)
178
+ URL.revokeObjectURL(blobUrl)
179
+ } catch {
180
+ setDownloadError(true)
181
+ } finally {
182
+ setDownloading(false)
183
+ }
184
+ }
185
+
186
+ // Stop click propagation so that events from the Dialog portal (which bubble
187
+ // through the React component tree, not the DOM) do not reach parent row
188
+ // click handlers and trigger unintended navigation (e.g. list-page TableRow
189
+ // opening edit on backdrop/dialog click).
190
+ const stopClick = (e: React.MouseEvent) => e.stopPropagation()
191
+
192
+ return (
193
+ <div className={cn('inline-flex items-center gap-2', className)} onClick={stopClick}>
194
+ <Button
195
+ type="button"
196
+ variant={triggerVariant}
197
+ size={triggerSize}
198
+ onClick={() => setOpen(true)}
199
+ disabled={!url}
200
+ >
201
+ <Eye className="size-4" />
202
+ <span>{previewLabel}</span>
203
+ </Button>
204
+
205
+ {showUrl && url ? (
206
+ <span
207
+ className="max-w-[24rem] truncate text-xs text-muted-foreground"
208
+ title={url}
209
+ >
210
+ {url}
211
+ </span>
212
+ ) : null}
213
+
214
+ <Dialog open={open} onOpenChange={setOpen}>
215
+ <DialogContent className="max-w-3xl">
216
+ <DialogHeader>
217
+ <DialogTitle>{titleLabel}</DialogTitle>
218
+ {labels?.description ? (
219
+ <DialogDescription>{labels.description}</DialogDescription>
220
+ ) : (
221
+ <DialogDescription className="break-all text-xs">{url}</DialogDescription>
222
+ )}
223
+ </DialogHeader>
224
+
225
+ <div className="flex max-h-[70vh] items-center justify-center overflow-auto rounded-md border border-border bg-muted/30 p-2">
226
+ {kindLoading ? (
227
+ <Loader2 className="size-8 animate-spin text-muted-foreground" />
228
+ ) : kind === 'image' ? (
229
+ <img
230
+ src={url}
231
+ alt={inferredName}
232
+ className="max-h-[68vh] w-auto max-w-full object-contain"
233
+ />
234
+ ) : kind === 'video' ? (
235
+ <video
236
+ src={url}
237
+ controls
238
+ className="max-h-[68vh] w-full max-w-full"
239
+ />
240
+ ) : kind === 'audio' ? (
241
+ <audio src={url} controls className="w-full" />
242
+ ) : (
243
+ <div className="p-6 text-center text-sm text-muted-foreground">
244
+ {cannotLabel}
245
+ </div>
246
+ )}
247
+ </div>
248
+
249
+ <DialogFooter>
250
+ {downloadError && (
251
+ <span className="flex items-center gap-1.5 text-xs text-destructive">
252
+ <AlertCircle className="size-3.5" />
253
+ {labels?.downloadError ?? 'Download failed'}
254
+ </span>
255
+ )}
256
+ <Button asChild variant="ghost" size="sm">
257
+ <a href={url} target="_blank" rel="noopener noreferrer">
258
+ <ExternalLink className="size-4" />
259
+ <span>{openLabel}</span>
260
+ </a>
261
+ </Button>
262
+ <Button
263
+ variant="default"
264
+ size="sm"
265
+ disabled={downloading}
266
+ onClick={() => { void handleDownload() }}
267
+ >
268
+ {downloading
269
+ ? <Loader2 className="size-4 animate-spin" />
270
+ : <Download className="size-4" />}
271
+ <span>{downloadLabel}</span>
272
+ </Button>
273
+ </DialogFooter>
274
+ </DialogContent>
275
+ </Dialog>
276
+ </div>
277
+ )
278
+ }
@@ -0,0 +1,315 @@
1
+ /**
2
+ * MultiFileInput — drag-and-drop file picker that accepts multiple files at
3
+ * once and renders a list of currently-attached files with per-file remove
4
+ * controls. Mirrors `FileInput` but works on an array of values.
5
+ *
6
+ * Purely presentational — upload + cancel logic lives in the
7
+ * `packages/react` property renderer.
8
+ */
9
+
10
+ import * as React from 'react'
11
+ import { Upload, X, FileText, Loader2 } from 'lucide-react'
12
+ import { cn } from '../lib/utils.js'
13
+ import { Button } from './button.js'
14
+
15
+ export interface MultiFileInputItem {
16
+ /** Storage key (or URL). */
17
+ value: string
18
+ /** Optional public URL used for image thumbnails. */
19
+ previewUrl?: string | null
20
+ /** Optional display name. Falls back to the last path segment of `value`. */
21
+ displayName?: string | null
22
+ }
23
+
24
+ /** In-progress upload — rendered as a separate row with progress / error. */
25
+ export interface MultiFileInputPendingItem {
26
+ /** Stable identifier for the pending row (e.g. `crypto.randomUUID()`). */
27
+ id: string
28
+ /** Display name (typically the local file name). */
29
+ name: string
30
+ /** 0–100. `undefined` while the request is queued or finished. */
31
+ progress?: number
32
+ /** Status text — when present, replaces the progress bar. */
33
+ status?: 'queued' | 'uploading' | 'error'
34
+ /** Error message to render when `status === 'error'`. */
35
+ error?: string
36
+ }
37
+
38
+ export interface MultiFileInputLabels {
39
+ /** Dropzone aria-label. Default: "Choose files". */
40
+ chooseFiles?: string
41
+ /** Drop zone hint before the link. Default: "Drag and drop or". */
42
+ dragAndDrop?: string
43
+ /** Link text when no files selected. Default: "choose files". */
44
+ chooseLink?: string
45
+ /** Link text when files already exist. Default: "add more files". */
46
+ addMoreLink?: string
47
+ /** Upload spinner text. Default: "Uploading…". */
48
+ uploading?: string
49
+ /** Remove button aria-label. Default: "Remove file". */
50
+ removeFile?: string
51
+ /** Fallback error when pending item has no message. Default: "Upload failed". */
52
+ uploadFailed?: string
53
+ /** Dismiss error button aria-label. Default: "Dismiss". */
54
+ dismiss?: string
55
+ }
56
+
57
+ export interface MultiFileInputProps {
58
+ /** Currently attached files. */
59
+ items: ReadonlyArray<MultiFileInputItem>
60
+ /** Files currently being uploaded — rendered below the persisted list. */
61
+ pendingItems?: ReadonlyArray<MultiFileInputPendingItem>
62
+ /** HTML `accept` attribute (e.g. `'image/*'` or `'.pdf,.docx'`). */
63
+ accept?: string
64
+ /**
65
+ * Whether files are currently being uploaded. Shows a spinner in the
66
+ * dropzone. Ignored when `pendingItems` is non-empty (the per-item
67
+ * progress rows take over the role of the dropzone spinner).
68
+ */
69
+ uploading?: boolean
70
+ /** Upload error message. */
71
+ error?: string
72
+ disabled?: boolean
73
+ className?: string
74
+ /** Translated UI labels. All optional — English strings are the defaults. */
75
+ labels?: MultiFileInputLabels
76
+ /** Called when the user picks one or more new files. */
77
+ onFilesSelect: (files: File[]) => void
78
+ /** Called when the user removes the file at the given index. */
79
+ onRemove: (index: number) => void
80
+ /** Called when the user dismisses an errored pending item. */
81
+ onPendingDismiss?: (id: string) => void
82
+ }
83
+
84
+ const isImageUrl = (url: string | null | undefined): boolean =>
85
+ !!url && /\.(jpe?g|png|gif|webp|avif|svg|bmp)(\?|$)/i.test(url)
86
+
87
+ export function MultiFileInput({
88
+ items,
89
+ pendingItems,
90
+ accept,
91
+ uploading = false,
92
+ error,
93
+ disabled = false,
94
+ className,
95
+ labels,
96
+ onFilesSelect,
97
+ onRemove,
98
+ onPendingDismiss,
99
+ }: MultiFileInputProps): React.ReactElement {
100
+ const l = {
101
+ chooseFiles: labels?.chooseFiles ?? 'Choose files',
102
+ dragAndDrop: labels?.dragAndDrop ?? 'Drag and drop or',
103
+ chooseLink: labels?.chooseLink ?? 'choose files',
104
+ addMoreLink: labels?.addMoreLink ?? 'add more files',
105
+ uploading: labels?.uploading ?? 'Uploading…',
106
+ removeFile: labels?.removeFile ?? 'Remove file',
107
+ uploadFailed: labels?.uploadFailed ?? 'Upload failed',
108
+ dismiss: labels?.dismiss ?? 'Dismiss',
109
+ }
110
+ const inputRef = React.useRef<HTMLInputElement>(null)
111
+ const [isDragging, setIsDragging] = React.useState(false)
112
+ const hasPending = (pendingItems?.length ?? 0) > 0
113
+ // The dropzone spinner is redundant when individual progress rows are
114
+ // already on screen — only show it for batch uploads that don't supply
115
+ // per-item progress.
116
+ const showSpinner = uploading && !hasPending
117
+
118
+ const handleFiles = (files: FileList | null): void => {
119
+ if (!files || files.length === 0 || disabled) return
120
+ onFilesSelect(Array.from(files))
121
+ }
122
+
123
+ const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
124
+ handleFiles(e.target.files)
125
+ // Allow re-selecting the same file again immediately after removing it.
126
+ e.target.value = ''
127
+ }
128
+
129
+ const handleDrop = (e: React.DragEvent<HTMLDivElement>): void => {
130
+ e.preventDefault()
131
+ setIsDragging(false)
132
+ handleFiles(e.dataTransfer.files)
133
+ }
134
+
135
+ const handleDragOver = (e: React.DragEvent<HTMLDivElement>): void => {
136
+ e.preventDefault()
137
+ if (!disabled) setIsDragging(true)
138
+ }
139
+
140
+ const handleDragLeave = (): void => setIsDragging(false)
141
+
142
+ return (
143
+ <div className={cn('w-full space-y-2', className)}>
144
+ {/* Drop zone / trigger */}
145
+ <div
146
+ role="button"
147
+ tabIndex={disabled ? -1 : 0}
148
+ aria-label={l.chooseFiles}
149
+ onClick={() => !disabled && inputRef.current?.click()}
150
+ onKeyDown={(e) => {
151
+ if ((e.key === 'Enter' || e.key === ' ') && !disabled) {
152
+ e.preventDefault()
153
+ inputRef.current?.click()
154
+ }
155
+ }}
156
+ onDrop={handleDrop}
157
+ onDragOver={handleDragOver}
158
+ onDragLeave={handleDragLeave}
159
+ className={cn(
160
+ 'flex min-h-[5rem] w-full cursor-pointer flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed border-border bg-muted/30 px-4 py-6 text-center transition-colors',
161
+ 'hover:border-primary/50 hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
162
+ isDragging && 'border-primary bg-primary/5',
163
+ disabled && 'cursor-not-allowed opacity-60',
164
+ )}
165
+ >
166
+ {showSpinner ? (
167
+ <>
168
+ <Loader2 className="size-6 animate-spin text-muted-foreground" />
169
+ <span className="text-sm text-muted-foreground">{l.uploading}</span>
170
+ </>
171
+ ) : (
172
+ <>
173
+ <Upload className="size-6 text-muted-foreground" />
174
+ <span className="text-sm font-medium text-foreground">
175
+ <span className="hidden sm:inline">{l.dragAndDrop} </span>
176
+ <span className="text-primary underline-offset-2 hover:underline">
177
+ {items.length > 0 ? l.addMoreLink : l.chooseLink}
178
+ </span>
179
+ </span>
180
+ {accept && (
181
+ <span className="text-xs text-muted-foreground">{accept}</span>
182
+ )}
183
+ </>
184
+ )}
185
+ </div>
186
+
187
+ {/* Current file list */}
188
+ {items.length > 0 && (
189
+ <ul className="space-y-1">
190
+ {items.map((item, idx) => {
191
+ const label = item.displayName || (item.value ? item.value.split('/').pop() : null)
192
+ const showImage = isImageUrl(item.previewUrl)
193
+ return (
194
+ <li
195
+ key={`${item.value}-${idx}`}
196
+ className="flex items-center gap-2 rounded-md border border-border bg-background px-3 py-2"
197
+ >
198
+ {showImage && item.previewUrl ? (
199
+ <img
200
+ src={item.previewUrl}
201
+ alt={label ?? 'preview'}
202
+ className="size-10 shrink-0 rounded object-cover"
203
+ />
204
+ ) : (
205
+ <FileText className="size-5 shrink-0 text-muted-foreground" />
206
+ )}
207
+ <span
208
+ className="min-w-0 flex-1 truncate text-sm text-foreground"
209
+ title={label ?? item.value}
210
+ >
211
+ {label ?? item.value}
212
+ </span>
213
+ {!disabled && (
214
+ <Button
215
+ type="button"
216
+ variant="ghost"
217
+ size="icon"
218
+ className="size-7 shrink-0 text-muted-foreground hover:text-destructive"
219
+ aria-label={l.removeFile}
220
+ onClick={(e) => {
221
+ e.stopPropagation()
222
+ onRemove(idx)
223
+ }}
224
+ >
225
+ <X className="size-4" />
226
+ </Button>
227
+ )}
228
+ </li>
229
+ )
230
+ })}
231
+ </ul>
232
+ )}
233
+
234
+ {/* In-progress uploads */}
235
+ {hasPending && (
236
+ <ul className="space-y-1">
237
+ {pendingItems!.map((p) => (
238
+ <li
239
+ key={p.id}
240
+ className={cn(
241
+ 'flex items-center gap-3 rounded-md border bg-background px-3 py-2',
242
+ p.status === 'error' ? 'border-destructive/50' : 'border-border',
243
+ )}
244
+ >
245
+ {p.status === 'error' ? (
246
+ <FileText className="size-5 shrink-0 text-destructive" />
247
+ ) : (
248
+ <Loader2 className="size-5 shrink-0 animate-spin text-muted-foreground" />
249
+ )}
250
+ <div className="min-w-0 flex-1">
251
+ <div className="truncate text-sm text-foreground" title={p.name}>
252
+ {p.name}
253
+ </div>
254
+ {p.status === 'error' ? (
255
+ <div className="text-xs text-destructive" role="alert">
256
+ {p.error || l.uploadFailed}
257
+ </div>
258
+ ) : (
259
+ <div className="mt-1 flex items-center gap-2">
260
+ <div
261
+ className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted"
262
+ role="progressbar"
263
+ aria-valuemin={0}
264
+ aria-valuemax={100}
265
+ aria-valuenow={p.progress ?? 0}
266
+ >
267
+ <div
268
+ className="h-full rounded-full bg-primary transition-[width] duration-150 ease-out"
269
+ style={{ width: `${p.progress ?? 0}%` }}
270
+ />
271
+ </div>
272
+ <span className="w-10 shrink-0 text-right text-xs tabular-nums text-muted-foreground">
273
+ {p.progress != null ? `${p.progress}%` : '…'}
274
+ </span>
275
+ </div>
276
+ )}
277
+ </div>
278
+ {p.status === 'error' && onPendingDismiss && (
279
+ <Button
280
+ type="button"
281
+ variant="ghost"
282
+ size="icon"
283
+ className="size-7 shrink-0 text-muted-foreground hover:text-destructive"
284
+ aria-label={l.dismiss}
285
+ onClick={(e) => {
286
+ e.stopPropagation()
287
+ onPendingDismiss(p.id)
288
+ }}
289
+ >
290
+ <X className="size-4" />
291
+ </Button>
292
+ )}
293
+ </li>
294
+ ))}
295
+ </ul>
296
+ )}
297
+
298
+ {/* Error message */}
299
+ {error && <p className="text-sm text-destructive">{error}</p>}
300
+
301
+ {/* Hidden native input */}
302
+ <input
303
+ ref={inputRef}
304
+ type="file"
305
+ accept={accept}
306
+ multiple
307
+ className="sr-only"
308
+ onChange={handleInputChange}
309
+ disabled={disabled}
310
+ tabIndex={-1}
311
+ aria-hidden
312
+ />
313
+ </div>
314
+ )
315
+ }
@@ -0,0 +1,50 @@
1
+ // Password field with a built-in show/hide toggle. Drops in anywhere a
2
+ // regular <Input type="password" /> would — it forwards refs and accepts
3
+ // the same props (the `type` prop is intentionally ignored).
4
+
5
+ import * as React from 'react'
6
+ import { Eye, EyeOff } from 'lucide-react'
7
+ import { cn } from '../lib/utils.js'
8
+
9
+ export interface PasswordInputProps
10
+ extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
11
+ /** Accessible label for the visibility toggle button. */
12
+ toggleLabel?: { show: string; hide: string }
13
+ }
14
+
15
+ export const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>(
16
+ ({ className, toggleLabel, disabled, ...props }, ref) => {
17
+ const [revealed, setRevealed] = React.useState(false)
18
+ const labels = toggleLabel ?? { show: 'Show password', hide: 'Hide password' }
19
+ return (
20
+ <div className="relative">
21
+ <input
22
+ ref={ref}
23
+ type={revealed ? 'text' : 'password'}
24
+ disabled={disabled}
25
+ className={cn(
26
+ 'flex h-9 w-full rounded-md border border-input bg-background pl-3 pr-10 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
27
+ className,
28
+ )}
29
+ {...props}
30
+ />
31
+ <button
32
+ type="button"
33
+ tabIndex={-1}
34
+ onClick={() => setRevealed((v) => !v)}
35
+ disabled={disabled}
36
+ aria-label={revealed ? labels.hide : labels.show}
37
+ aria-pressed={revealed}
38
+ className="absolute inset-y-0 right-0 flex w-9 cursor-pointer items-center justify-center rounded-r-md text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
39
+ >
40
+ {revealed ? (
41
+ <EyeOff className="size-4" aria-hidden="true" />
42
+ ) : (
43
+ <Eye className="size-4" aria-hidden="true" />
44
+ )}
45
+ </button>
46
+ </div>
47
+ )
48
+ },
49
+ )
50
+ PasswordInput.displayName = 'PasswordInput'
@@ -0,0 +1,26 @@
1
+ import * as React from 'react'
2
+ import * as PopoverPrimitive from '@radix-ui/react-popover'
3
+ import { cn } from '../lib/utils.js'
4
+
5
+ export const Popover = PopoverPrimitive.Root
6
+ export const PopoverTrigger = PopoverPrimitive.Trigger
7
+ export const PopoverAnchor = PopoverPrimitive.Anchor
8
+
9
+ export const PopoverContent = React.forwardRef<
10
+ React.ElementRef<typeof PopoverPrimitive.Content>,
11
+ React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
12
+ >(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
13
+ <PopoverPrimitive.Portal>
14
+ <PopoverPrimitive.Content
15
+ ref={ref}
16
+ align={align}
17
+ sideOffset={sideOffset}
18
+ className={cn(
19
+ 'z-50 w-72 rounded-md border border-border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2',
20
+ className,
21
+ )}
22
+ {...props}
23
+ />
24
+ </PopoverPrimitive.Portal>
25
+ ))
26
+ PopoverContent.displayName = PopoverPrimitive.Content.displayName