@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.
- package/dist/components/accordion.d.ts +7 -0
- package/dist/components/accordion.d.ts.map +1 -0
- package/dist/components/accordion.jsx +19 -0
- package/dist/components/accordion.jsx.map +1 -0
- package/dist/components/alert-dialog.d.ts +22 -0
- package/dist/components/alert-dialog.d.ts.map +1 -0
- package/dist/components/alert-dialog.jsx +27 -0
- package/dist/components/alert-dialog.jsx.map +1 -0
- package/dist/components/audit-timeline.d.ts +24 -0
- package/dist/components/audit-timeline.d.ts.map +1 -0
- package/dist/components/audit-timeline.jsx +60 -0
- package/dist/components/audit-timeline.jsx.map +1 -0
- package/dist/components/avatar.d.ts +6 -0
- package/dist/components/avatar.d.ts.map +1 -0
- package/dist/components/avatar.jsx +10 -0
- package/dist/components/avatar.jsx.map +1 -0
- package/dist/components/badge.d.ts +10 -0
- package/dist/components/badge.d.ts.map +1 -0
- package/dist/components/badge.jsx +19 -0
- package/dist/components/badge.jsx.map +1 -0
- package/dist/components/breadcrumb.d.ts +17 -0
- package/dist/components/breadcrumb.d.ts.map +1 -0
- package/dist/components/breadcrumb.jsx +27 -0
- package/dist/components/breadcrumb.jsx.map +1 -0
- package/dist/components/button.d.ts +12 -0
- package/dist/components/button.d.ts.map +1 -0
- package/dist/components/button.jsx +37 -0
- package/dist/components/button.jsx.map +1 -0
- package/dist/components/calendar.d.ts +9 -0
- package/dist/components/calendar.d.ts.map +1 -0
- package/dist/components/calendar.jsx +102 -0
- package/dist/components/calendar.jsx.map +1 -0
- package/dist/components/card.d.ts +8 -0
- package/dist/components/card.d.ts.map +1 -0
- package/dist/components/card.jsx +18 -0
- package/dist/components/card.jsx.map +1 -0
- package/dist/components/chart.d.ts +97 -0
- package/dist/components/chart.d.ts.map +1 -0
- package/dist/components/chart.jsx +233 -0
- package/dist/components/chart.jsx.map +1 -0
- package/dist/components/checkbox.d.ts +4 -0
- package/dist/components/checkbox.d.ts.map +1 -0
- package/dist/components/checkbox.jsx +11 -0
- package/dist/components/checkbox.jsx.map +1 -0
- package/dist/components/combobox.d.ts +46 -0
- package/dist/components/combobox.d.ts.map +1 -0
- package/dist/components/combobox.jsx +145 -0
- package/dist/components/combobox.jsx.map +1 -0
- package/dist/components/command.d.ts +80 -0
- package/dist/components/command.d.ts.map +1 -0
- package/dist/components/command.jsx +32 -0
- package/dist/components/command.jsx.map +1 -0
- package/dist/components/date-picker.d.ts +24 -0
- package/dist/components/date-picker.d.ts.map +1 -0
- package/dist/components/date-picker.jsx +149 -0
- package/dist/components/date-picker.jsx.map +1 -0
- package/dist/components/date-range-input.d.ts +22 -0
- package/dist/components/date-range-input.d.ts.map +1 -0
- package/dist/components/date-range-input.jsx +202 -0
- package/dist/components/date-range-input.jsx.map +1 -0
- package/dist/components/dialog.d.ts +19 -0
- package/dist/components/dialog.d.ts.map +1 -0
- package/dist/components/dialog.jsx +30 -0
- package/dist/components/dialog.jsx.map +1 -0
- package/dist/components/diff-view.d.ts +24 -0
- package/dist/components/diff-view.d.ts.map +1 -0
- package/dist/components/diff-view.jsx +69 -0
- package/dist/components/diff-view.jsx.map +1 -0
- package/dist/components/dropdown-menu.d.ts +27 -0
- package/dist/components/dropdown-menu.d.ts.map +1 -0
- package/dist/components/dropdown-menu.jsx +48 -0
- package/dist/components/dropdown-menu.jsx.map +1 -0
- package/dist/components/empty.d.ts +15 -0
- package/dist/components/empty.d.ts.map +1 -0
- package/dist/components/empty.jsx +27 -0
- package/dist/components/empty.jsx.map +1 -0
- package/dist/components/field.d.ts +23 -0
- package/dist/components/field.d.ts.map +1 -0
- package/dist/components/field.jsx +60 -0
- package/dist/components/field.jsx.map +1 -0
- package/dist/components/file-input.d.ts +50 -0
- package/dist/components/file-input.d.ts.map +1 -0
- package/dist/components/file-input.jsx +104 -0
- package/dist/components/file-input.jsx.map +1 -0
- package/dist/components/form.d.ts +20 -0
- package/dist/components/form.d.ts.map +1 -0
- package/dist/components/form.jsx +66 -0
- package/dist/components/form.jsx.map +1 -0
- package/dist/components/info-tooltip.d.ts +11 -0
- package/dist/components/info-tooltip.d.ts.map +1 -0
- package/dist/components/info-tooltip.jsx +17 -0
- package/dist/components/info-tooltip.jsx.map +1 -0
- package/dist/components/input.d.ts +13 -0
- package/dist/components/input.d.ts.map +1 -0
- package/dist/components/input.jsx +19 -0
- package/dist/components/input.jsx.map +1 -0
- package/dist/components/json-editor.d.ts +23 -0
- package/dist/components/json-editor.d.ts.map +1 -0
- package/dist/components/json-editor.jsx +143 -0
- package/dist/components/json-editor.jsx.map +1 -0
- package/dist/components/kbd.d.ts +15 -0
- package/dist/components/kbd.d.ts.map +1 -0
- package/dist/components/kbd.jsx +23 -0
- package/dist/components/kbd.jsx.map +1 -0
- package/dist/components/key-value-editor.d.ts +92 -0
- package/dist/components/key-value-editor.d.ts.map +1 -0
- package/dist/components/key-value-editor.jsx +187 -0
- package/dist/components/key-value-editor.jsx.map +1 -0
- package/dist/components/keyboard-shortcuts-help.d.ts +17 -0
- package/dist/components/keyboard-shortcuts-help.d.ts.map +1 -0
- package/dist/components/keyboard-shortcuts-help.jsx +97 -0
- package/dist/components/keyboard-shortcuts-help.jsx.map +1 -0
- package/dist/components/label.d.ts +5 -0
- package/dist/components/label.d.ts.map +1 -0
- package/dist/components/label.jsx +8 -0
- package/dist/components/label.jsx.map +1 -0
- package/dist/components/media-preview.d.ts +30 -0
- package/dist/components/media-preview.d.ts.map +1 -0
- package/dist/components/media-preview.jsx +189 -0
- package/dist/components/media-preview.jsx.map +1 -0
- package/dist/components/multi-file-input.d.ts +76 -0
- package/dist/components/multi-file-input.d.ts.map +1 -0
- package/dist/components/multi-file-input.jsx +131 -0
- package/dist/components/multi-file-input.jsx.map +1 -0
- package/dist/components/password-input.d.ts +10 -0
- package/dist/components/password-input.d.ts.map +1 -0
- package/dist/components/password-input.jsx +18 -0
- package/dist/components/password-input.jsx.map +1 -0
- package/dist/components/popover.d.ts +7 -0
- package/dist/components/popover.d.ts.map +1 -0
- package/dist/components/popover.jsx +11 -0
- package/dist/components/popover.jsx.map +1 -0
- package/dist/components/revision-timeline.d.ts +30 -0
- package/dist/components/revision-timeline.d.ts.map +1 -0
- package/dist/components/revision-timeline.jsx +42 -0
- package/dist/components/revision-timeline.jsx.map +1 -0
- package/dist/components/richtext-editor.d.ts +43 -0
- package/dist/components/richtext-editor.d.ts.map +1 -0
- package/dist/components/richtext-editor.jsx +319 -0
- package/dist/components/richtext-editor.jsx.map +1 -0
- package/dist/components/richtext-mode.d.ts +23 -0
- package/dist/components/richtext-mode.d.ts.map +1 -0
- package/dist/components/richtext-mode.js +36 -0
- package/dist/components/richtext-mode.js.map +1 -0
- package/dist/components/richtext-render.d.ts +8 -0
- package/dist/components/richtext-render.d.ts.map +1 -0
- package/dist/components/richtext-render.jsx +33 -0
- package/dist/components/richtext-render.jsx.map +1 -0
- package/dist/components/richtext-sync.d.ts +37 -0
- package/dist/components/richtext-sync.d.ts.map +1 -0
- package/dist/components/richtext-sync.js +46 -0
- package/dist/components/richtext-sync.js.map +1 -0
- package/dist/components/scroll-area.d.ts +5 -0
- package/dist/components/scroll-area.d.ts.map +1 -0
- package/dist/components/scroll-area.jsx +16 -0
- package/dist/components/scroll-area.jsx.map +1 -0
- package/dist/components/select.d.ts +36 -0
- package/dist/components/select.d.ts.map +1 -0
- package/dist/components/select.jsx +87 -0
- package/dist/components/select.jsx.map +1 -0
- package/dist/components/separator.d.ts +4 -0
- package/dist/components/separator.d.ts.map +1 -0
- package/dist/components/separator.jsx +6 -0
- package/dist/components/separator.jsx.map +1 -0
- package/dist/components/sheet.d.ts +29 -0
- package/dist/components/sheet.d.ts.map +1 -0
- package/dist/components/sheet.jsx +44 -0
- package/dist/components/sheet.jsx.map +1 -0
- package/dist/components/sidebar.d.ts +70 -0
- package/dist/components/sidebar.d.ts.map +1 -0
- package/dist/components/sidebar.jsx +245 -0
- package/dist/components/sidebar.jsx.map +1 -0
- package/dist/components/skeleton.d.ts +3 -0
- package/dist/components/skeleton.d.ts.map +1 -0
- package/dist/components/skeleton.jsx +6 -0
- package/dist/components/skeleton.jsx.map +1 -0
- package/dist/components/sonner.d.ts +6 -0
- package/dist/components/sonner.d.ts.map +1 -0
- package/dist/components/sonner.jsx +29 -0
- package/dist/components/sonner.jsx.map +1 -0
- package/dist/components/switch.d.ts +4 -0
- package/dist/components/switch.d.ts.map +1 -0
- package/dist/components/switch.jsx +8 -0
- package/dist/components/switch.jsx.map +1 -0
- package/dist/components/table.d.ts +10 -0
- package/dist/components/table.d.ts.map +1 -0
- package/dist/components/table.jsx +21 -0
- package/dist/components/table.jsx.map +1 -0
- package/dist/components/tabs.d.ts +7 -0
- package/dist/components/tabs.d.ts.map +1 -0
- package/dist/components/tabs.jsx +14 -0
- package/dist/components/tabs.jsx.map +1 -0
- package/dist/components/textarea.d.ts +4 -0
- package/dist/components/textarea.d.ts.map +1 -0
- package/dist/components/textarea.jsx +5 -0
- package/dist/components/textarea.jsx.map +1 -0
- package/dist/components/tooltip.d.ts +7 -0
- package/dist/components/tooltip.d.ts.map +1 -0
- package/dist/components/tooltip.jsx +11 -0
- package/dist/components/tooltip.jsx.map +1 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +72 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/theme.d.ts +11 -0
- package/dist/lib/theme.d.ts.map +1 -0
- package/dist/lib/theme.js +44 -0
- package/dist/lib/theme.js.map +1 -0
- package/dist/lib/utils.d.ts +3 -0
- package/dist/lib/utils.d.ts.map +1 -0
- package/dist/lib/utils.js +6 -0
- package/dist/lib/utils.js.map +1 -0
- package/dist/styles.css +242 -0
- package/package.json +85 -0
- package/src/components/accordion.tsx +48 -0
- package/src/components/alert-dialog.tsx +113 -0
- package/src/components/audit-timeline.tsx +102 -0
- package/src/components/avatar.tsx +42 -0
- package/src/components/badge.tsx +34 -0
- package/src/components/breadcrumb.tsx +99 -0
- package/src/components/button.tsx +58 -0
- package/src/components/calendar.tsx +176 -0
- package/src/components/card.tsx +60 -0
- package/src/components/chart.tsx +558 -0
- package/src/components/checkbox.tsx +23 -0
- package/src/components/combobox.tsx +264 -0
- package/src/components/command.tsx +120 -0
- package/src/components/date-picker.tsx +221 -0
- package/src/components/date-range-input.tsx +295 -0
- package/src/components/dialog.tsx +94 -0
- package/src/components/diff-view.tsx +182 -0
- package/src/components/dropdown-menu.tsx +165 -0
- package/src/components/empty.tsx +100 -0
- package/src/components/field.tsx +168 -0
- package/src/components/file-input.tsx +233 -0
- package/src/components/form.tsx +152 -0
- package/src/components/info-tooltip.tsx +40 -0
- package/src/components/input.tsx +55 -0
- package/src/components/json-editor.tsx +210 -0
- package/src/components/kbd.tsx +35 -0
- package/src/components/key-value-editor.tsx +423 -0
- package/src/components/keyboard-shortcuts-help.tsx +136 -0
- package/src/components/label.tsx +16 -0
- package/src/components/media-preview.tsx +278 -0
- package/src/components/multi-file-input.tsx +315 -0
- package/src/components/password-input.tsx +50 -0
- package/src/components/popover.tsx +26 -0
- package/src/components/revision-timeline.tsx +93 -0
- package/src/components/richtext-editor.tsx +624 -0
- package/src/components/richtext-mode.ts +39 -0
- package/src/components/richtext-render.tsx +51 -0
- package/src/components/richtext-sync.ts +57 -0
- package/src/components/scroll-area.tsx +41 -0
- package/src/components/select.tsx +200 -0
- package/src/components/separator.tsx +21 -0
- package/src/components/sheet.tsx +109 -0
- package/src/components/sidebar.tsx +660 -0
- package/src/components/skeleton.tsx +9 -0
- package/src/components/sonner.tsx +45 -0
- package/src/components/switch.tsx +24 -0
- package/src/components/table.tsx +93 -0
- package/src/components/tabs.tsx +57 -0
- package/src/components/textarea.tsx +18 -0
- package/src/components/tooltip.tsx +25 -0
- package/src/index.ts +342 -0
- package/src/lib/theme.ts +45 -0
- package/src/lib/utils.ts +6 -0
- 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
|