@dilipod/ui 0.3.3 → 0.4.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/error-state.d.ts +26 -0
- package/dist/components/error-state.d.ts.map +1 -0
- package/dist/components/file-preview.d.ts +25 -0
- package/dist/components/file-preview.d.ts.map +1 -0
- package/dist/components/metric-label.d.ts +60 -0
- package/dist/components/metric-label.d.ts.map +1 -0
- package/dist/components/scenarios-manager.d.ts +23 -0
- package/dist/components/scenarios-manager.d.ts.map +1 -0
- package/dist/components/tabs.d.ts.map +1 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +845 -122
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +840 -123
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/components/error-state.tsx +118 -0
- package/src/components/file-preview.tsx +359 -0
- package/src/components/metric-label.tsx +135 -0
- package/src/components/scenarios-manager.tsx +403 -0
- package/src/components/tabs.tsx +3 -1
- package/src/index.ts +14 -0
package/package.json
CHANGED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { Card, CardContent } from './card'
|
|
5
|
+
import { IconBox } from './icon-box'
|
|
6
|
+
import { Button } from './button'
|
|
7
|
+
import { cn } from '../lib/utils'
|
|
8
|
+
|
|
9
|
+
export interface ErrorStateProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
10
|
+
/** Title text */
|
|
11
|
+
title?: string
|
|
12
|
+
/** Description text */
|
|
13
|
+
description?: string
|
|
14
|
+
/** Whether to show the retry button */
|
|
15
|
+
showRetry?: boolean
|
|
16
|
+
/** Whether to show the home/dashboard link */
|
|
17
|
+
showHomeLink?: boolean
|
|
18
|
+
/** Custom retry button text */
|
|
19
|
+
retryText?: string
|
|
20
|
+
/** Custom home link text */
|
|
21
|
+
homeLinkText?: string
|
|
22
|
+
/** Callback when retry is clicked */
|
|
23
|
+
onRetry?: () => void
|
|
24
|
+
/** Callback when home link is clicked */
|
|
25
|
+
onHomeClick?: () => void
|
|
26
|
+
/** Custom icon to display */
|
|
27
|
+
icon?: React.ReactNode
|
|
28
|
+
/** Custom action buttons */
|
|
29
|
+
actions?: React.ReactNode
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const ErrorState = React.forwardRef<HTMLDivElement, ErrorStateProps>(
|
|
33
|
+
(
|
|
34
|
+
{
|
|
35
|
+
title = 'Something went wrong',
|
|
36
|
+
description = 'We encountered an error loading this page. Please try again.',
|
|
37
|
+
showRetry = true,
|
|
38
|
+
showHomeLink = true,
|
|
39
|
+
retryText = 'Try again',
|
|
40
|
+
homeLinkText = 'Go to dashboard',
|
|
41
|
+
onRetry,
|
|
42
|
+
onHomeClick,
|
|
43
|
+
icon,
|
|
44
|
+
actions,
|
|
45
|
+
className,
|
|
46
|
+
...props
|
|
47
|
+
},
|
|
48
|
+
ref
|
|
49
|
+
) => {
|
|
50
|
+
// Default icon - Warning icon
|
|
51
|
+
const defaultIcon = (
|
|
52
|
+
<svg
|
|
53
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
54
|
+
width="24"
|
|
55
|
+
height="24"
|
|
56
|
+
fill="currentColor"
|
|
57
|
+
viewBox="0 0 256 256"
|
|
58
|
+
className="text-red-500"
|
|
59
|
+
>
|
|
60
|
+
<path d="M236.8,188.09,149.35,36.22h0a24.76,24.76,0,0,0-42.7,0L19.2,188.09a23.51,23.51,0,0,0,0,23.72A24.35,24.35,0,0,0,40.55,224h174.9a24.35,24.35,0,0,0,21.33-12.19A23.51,23.51,0,0,0,236.8,188.09ZM120,104a8,8,0,0,1,16,0v40a8,8,0,0,1-16,0Zm8,88a12,12,0,1,1,12-12A12,12,0,0,1,128,192Z" />
|
|
61
|
+
</svg>
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
// Default refresh icon for retry button
|
|
65
|
+
const refreshIcon = (
|
|
66
|
+
<svg
|
|
67
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
68
|
+
width="16"
|
|
69
|
+
height="16"
|
|
70
|
+
fill="currentColor"
|
|
71
|
+
viewBox="0 0 256 256"
|
|
72
|
+
className="mr-2"
|
|
73
|
+
>
|
|
74
|
+
<path d="M240,56v48a8,8,0,0,1-8,8H184a8,8,0,0,1,0-16H211.4L184.81,71.64A80,80,0,1,0,207.6,176.16a8,8,0,1,1,13.54,8.49A96,96,0,1,1,227.59,64l.3-.31L208,44.31V56a8,8,0,0,0,8,8h16A8,8,0,0,0,240,56Z" />
|
|
75
|
+
</svg>
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
const hasDefaultActions = (showRetry && onRetry) || (showHomeLink && onHomeClick)
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div
|
|
82
|
+
ref={ref}
|
|
83
|
+
className={cn('flex items-center justify-center min-h-[60vh]', className)}
|
|
84
|
+
{...props}
|
|
85
|
+
>
|
|
86
|
+
<Card className="max-w-md w-full">
|
|
87
|
+
<CardContent className="p-8 text-center">
|
|
88
|
+
<IconBox size="lg" className="mx-auto mb-4 bg-red-50">
|
|
89
|
+
{icon || defaultIcon}
|
|
90
|
+
</IconBox>
|
|
91
|
+
<h2 className="text-lg font-semibold text-[var(--black)] mb-2">
|
|
92
|
+
{title}
|
|
93
|
+
</h2>
|
|
94
|
+
<p className="text-sm text-muted-foreground mb-6">{description}</p>
|
|
95
|
+
{actions ? (
|
|
96
|
+
<div className="flex gap-3 justify-center">{actions}</div>
|
|
97
|
+
) : hasDefaultActions ? (
|
|
98
|
+
<div className="flex gap-3 justify-center">
|
|
99
|
+
{showRetry && onRetry && (
|
|
100
|
+
<Button variant="outline" onClick={onRetry}>
|
|
101
|
+
{refreshIcon}
|
|
102
|
+
{retryText}
|
|
103
|
+
</Button>
|
|
104
|
+
)}
|
|
105
|
+
{showHomeLink && onHomeClick && (
|
|
106
|
+
<Button onClick={onHomeClick}>{homeLinkText}</Button>
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
109
|
+
) : null}
|
|
110
|
+
</CardContent>
|
|
111
|
+
</Card>
|
|
112
|
+
</div>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
ErrorState.displayName = 'ErrorState'
|
|
117
|
+
|
|
118
|
+
export { ErrorState }
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useRef } from 'react'
|
|
4
|
+
import {
|
|
5
|
+
File,
|
|
6
|
+
FileVideo,
|
|
7
|
+
FileDoc,
|
|
8
|
+
FilePdf,
|
|
9
|
+
FileImage,
|
|
10
|
+
Download,
|
|
11
|
+
X,
|
|
12
|
+
Play,
|
|
13
|
+
ArrowSquareOut,
|
|
14
|
+
CircleNotch,
|
|
15
|
+
Folder
|
|
16
|
+
} from '@phosphor-icons/react'
|
|
17
|
+
import { Card, CardContent, CardHeader, CardTitle } from './card'
|
|
18
|
+
|
|
19
|
+
export interface UploadedFile {
|
|
20
|
+
path: string
|
|
21
|
+
filename: string
|
|
22
|
+
type: string
|
|
23
|
+
size: number
|
|
24
|
+
url?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface FilePreviewProps {
|
|
28
|
+
files: UploadedFile[]
|
|
29
|
+
title?: string
|
|
30
|
+
/**
|
|
31
|
+
* Function to get signed URL for a file. If not provided, uses the url property from the file object.
|
|
32
|
+
*/
|
|
33
|
+
getSignedUrl?: (path: string) => Promise<string | null>
|
|
34
|
+
/**
|
|
35
|
+
* Show as a card with header, or just the file list
|
|
36
|
+
*/
|
|
37
|
+
showCard?: boolean
|
|
38
|
+
/**
|
|
39
|
+
* Empty state message
|
|
40
|
+
*/
|
|
41
|
+
emptyMessage?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function getFileIcon(type: string) {
|
|
45
|
+
if (type.startsWith('video/') || type === 'screen_recording') return FileVideo
|
|
46
|
+
if (type.startsWith('image/') || type === 'image') return FileImage
|
|
47
|
+
if (type === 'application/pdf' || type === 'pdf') return FilePdf
|
|
48
|
+
if (type.includes('document') || type === 'document') return FileDoc
|
|
49
|
+
return File
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getTypeLabel(type: string): string {
|
|
53
|
+
if (type.startsWith('video/') || type === 'screen_recording') return 'Video'
|
|
54
|
+
if (type.startsWith('audio/')) return 'Audio'
|
|
55
|
+
if (type.startsWith('image/') || type === 'image') return 'Image'
|
|
56
|
+
if (type === 'application/pdf' || type === 'pdf') return 'PDF'
|
|
57
|
+
if (type.includes('document') || type === 'document') return 'Document'
|
|
58
|
+
return 'File'
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function formatSize(size: number): string {
|
|
62
|
+
if (size < 1024) return `${size} B`
|
|
63
|
+
if (size < 1024 * 1024) return `${Math.round(size / 1024)} KB`
|
|
64
|
+
return `${(size / (1024 * 1024)).toFixed(1)} MB`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function canPreview(file: UploadedFile): boolean {
|
|
68
|
+
const ext = file.filename.toLowerCase().split('.').pop() || ''
|
|
69
|
+
// Images
|
|
70
|
+
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) return true
|
|
71
|
+
// Videos
|
|
72
|
+
if (['mp4', 'webm', 'mov', 'avi', 'mkv'].includes(ext)) return true
|
|
73
|
+
// PDFs
|
|
74
|
+
if (ext === 'pdf') return true
|
|
75
|
+
// By type
|
|
76
|
+
if (file.type.startsWith('image/') || file.type === 'image') return true
|
|
77
|
+
if (file.type.startsWith('video/') || file.type === 'screen_recording') return true
|
|
78
|
+
if (file.type === 'application/pdf' || file.type === 'pdf') return true
|
|
79
|
+
return false
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function getPreviewType(file: UploadedFile): 'image' | 'video' | 'pdf' | 'none' {
|
|
83
|
+
const ext = file.filename.toLowerCase().split('.').pop() || ''
|
|
84
|
+
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext) || file.type.startsWith('image/') || file.type === 'image') return 'image'
|
|
85
|
+
if (['mp4', 'webm', 'mov', 'avi', 'mkv'].includes(ext) || file.type.startsWith('video/') || file.type === 'screen_recording') return 'video'
|
|
86
|
+
if (ext === 'pdf' || file.type === 'application/pdf' || file.type === 'pdf') return 'pdf'
|
|
87
|
+
return 'none'
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function FilePreview({
|
|
91
|
+
files,
|
|
92
|
+
title = 'Uploaded Files',
|
|
93
|
+
getSignedUrl,
|
|
94
|
+
showCard = true,
|
|
95
|
+
emptyMessage = 'No files uploaded'
|
|
96
|
+
}: FilePreviewProps) {
|
|
97
|
+
const [previewFile, setPreviewFile] = useState<UploadedFile | null>(null)
|
|
98
|
+
const [videoError, setVideoError] = useState(false)
|
|
99
|
+
const [signedUrl, setSignedUrl] = useState<string | null>(null)
|
|
100
|
+
const [loadingUrl, setLoadingUrl] = useState(false)
|
|
101
|
+
const [urlError, setUrlError] = useState<string | null>(null)
|
|
102
|
+
const videoRef = useRef<HTMLVideoElement>(null)
|
|
103
|
+
|
|
104
|
+
const handleOpenPreview = async (file: UploadedFile) => {
|
|
105
|
+
setVideoError(false)
|
|
106
|
+
setPreviewFile(file)
|
|
107
|
+
setUrlError(null)
|
|
108
|
+
|
|
109
|
+
// Get the URL
|
|
110
|
+
if (file.url) {
|
|
111
|
+
setSignedUrl(file.url)
|
|
112
|
+
} else if (getSignedUrl) {
|
|
113
|
+
setLoadingUrl(true)
|
|
114
|
+
try {
|
|
115
|
+
const url = await getSignedUrl(file.path)
|
|
116
|
+
if (url) {
|
|
117
|
+
setSignedUrl(url)
|
|
118
|
+
} else {
|
|
119
|
+
setUrlError('Failed to load file URL')
|
|
120
|
+
}
|
|
121
|
+
} catch (error) {
|
|
122
|
+
setUrlError(error instanceof Error ? error.message : 'Failed to load file')
|
|
123
|
+
} finally {
|
|
124
|
+
setLoadingUrl(false)
|
|
125
|
+
}
|
|
126
|
+
} else {
|
|
127
|
+
setUrlError('No URL available for this file')
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const handleClosePreview = () => {
|
|
132
|
+
setPreviewFile(null)
|
|
133
|
+
setSignedUrl(null)
|
|
134
|
+
setUrlError(null)
|
|
135
|
+
setLoadingUrl(false)
|
|
136
|
+
setVideoError(false)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const handleDownload = async (e: React.MouseEvent, file: UploadedFile) => {
|
|
140
|
+
e.stopPropagation()
|
|
141
|
+
|
|
142
|
+
let url = file.url
|
|
143
|
+
if (!url && getSignedUrl) {
|
|
144
|
+
url = await getSignedUrl(file.path) || undefined
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (url) {
|
|
148
|
+
window.open(url, '_blank')
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const content = (
|
|
153
|
+
<>
|
|
154
|
+
{files.length > 0 ? (
|
|
155
|
+
<div className="space-y-2">
|
|
156
|
+
{files.map((file, i) => {
|
|
157
|
+
const FileIcon = getFileIcon(file.type)
|
|
158
|
+
const typeLabel = getTypeLabel(file.type)
|
|
159
|
+
const sizeLabel = formatSize(file.size)
|
|
160
|
+
const isPreviewable = canPreview(file)
|
|
161
|
+
const previewType = getPreviewType(file)
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<div
|
|
165
|
+
key={i}
|
|
166
|
+
className={`flex items-center justify-between p-3 rounded-md bg-gray-50 transition-colors ${
|
|
167
|
+
isPreviewable ? 'hover:bg-gray-100 cursor-pointer' : ''
|
|
168
|
+
}`}
|
|
169
|
+
onClick={() => isPreviewable && handleOpenPreview(file)}
|
|
170
|
+
>
|
|
171
|
+
<div className="flex items-center gap-3 min-w-0">
|
|
172
|
+
<div className="relative shrink-0">
|
|
173
|
+
<div className="w-10 h-10 rounded-sm bg-white border border-gray-200 flex items-center justify-center">
|
|
174
|
+
<FileIcon className="w-5 h-5 text-[var(--cyan)]" weight="fill" />
|
|
175
|
+
</div>
|
|
176
|
+
{previewType === 'video' && (
|
|
177
|
+
<Play className="absolute -bottom-0.5 -right-0.5 w-3.5 h-3.5 text-[var(--cyan)] bg-white rounded-full" weight="fill" />
|
|
178
|
+
)}
|
|
179
|
+
</div>
|
|
180
|
+
<div className="min-w-0">
|
|
181
|
+
<p className="text-sm font-medium text-[var(--black)] truncate">{file.filename}</p>
|
|
182
|
+
<p className="text-xs text-muted-foreground">
|
|
183
|
+
{typeLabel} · {sizeLabel}
|
|
184
|
+
{isPreviewable && (
|
|
185
|
+
<span className="text-[var(--cyan)] ml-1">· Click to preview</span>
|
|
186
|
+
)}
|
|
187
|
+
</p>
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
<button
|
|
191
|
+
onClick={(e) => handleDownload(e, file)}
|
|
192
|
+
className="p-2 rounded-sm hover:bg-gray-200 transition-colors shrink-0"
|
|
193
|
+
title="Download"
|
|
194
|
+
>
|
|
195
|
+
<Download className="w-4 h-4 text-muted-foreground" />
|
|
196
|
+
</button>
|
|
197
|
+
</div>
|
|
198
|
+
)
|
|
199
|
+
})}
|
|
200
|
+
</div>
|
|
201
|
+
) : (
|
|
202
|
+
<div className="flex items-center justify-center h-32 border border-dashed border-gray-200 rounded-md">
|
|
203
|
+
<div className="text-center">
|
|
204
|
+
<Folder size={24} className="text-gray-300 mx-auto mb-2" />
|
|
205
|
+
<p className="text-sm text-muted-foreground">{emptyMessage}</p>
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
)}
|
|
209
|
+
|
|
210
|
+
{/* Preview Modal */}
|
|
211
|
+
{previewFile && (
|
|
212
|
+
<div
|
|
213
|
+
className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4"
|
|
214
|
+
onClick={handleClosePreview}
|
|
215
|
+
>
|
|
216
|
+
<div
|
|
217
|
+
className="bg-white rounded-lg max-w-5xl w-full max-h-[90vh] flex flex-col overflow-hidden shadow-2xl"
|
|
218
|
+
onClick={(e) => e.stopPropagation()}
|
|
219
|
+
>
|
|
220
|
+
{/* Header */}
|
|
221
|
+
<div className="flex items-center justify-between p-4 border-b border-gray-200">
|
|
222
|
+
<div className="flex items-center gap-3 min-w-0">
|
|
223
|
+
{(() => {
|
|
224
|
+
const FileIcon = getFileIcon(previewFile.type)
|
|
225
|
+
return <FileIcon className="w-5 h-5 text-[var(--cyan)] flex-shrink-0" weight="fill" />
|
|
226
|
+
})()}
|
|
227
|
+
<div className="min-w-0">
|
|
228
|
+
<p className="font-medium text-[var(--black)] truncate">{previewFile.filename}</p>
|
|
229
|
+
<p className="text-xs text-muted-foreground">
|
|
230
|
+
{getTypeLabel(previewFile.type)} · {formatSize(previewFile.size)}
|
|
231
|
+
</p>
|
|
232
|
+
</div>
|
|
233
|
+
</div>
|
|
234
|
+
<div className="flex items-center gap-1">
|
|
235
|
+
{signedUrl && (
|
|
236
|
+
<>
|
|
237
|
+
<a
|
|
238
|
+
href={signedUrl}
|
|
239
|
+
target="_blank"
|
|
240
|
+
rel="noopener noreferrer"
|
|
241
|
+
className="p-2 rounded-md hover:bg-gray-100 transition-colors"
|
|
242
|
+
title="Open in new tab"
|
|
243
|
+
>
|
|
244
|
+
<ArrowSquareOut className="w-5 h-5 text-muted-foreground" />
|
|
245
|
+
</a>
|
|
246
|
+
<a
|
|
247
|
+
href={signedUrl}
|
|
248
|
+
download={previewFile.filename}
|
|
249
|
+
className="p-2 rounded-md hover:bg-gray-100 transition-colors"
|
|
250
|
+
title="Download"
|
|
251
|
+
>
|
|
252
|
+
<Download className="w-5 h-5 text-muted-foreground" />
|
|
253
|
+
</a>
|
|
254
|
+
</>
|
|
255
|
+
)}
|
|
256
|
+
<button
|
|
257
|
+
onClick={handleClosePreview}
|
|
258
|
+
className="p-2 rounded-md hover:bg-gray-100 transition-colors ml-2"
|
|
259
|
+
>
|
|
260
|
+
<X className="w-5 h-5" />
|
|
261
|
+
</button>
|
|
262
|
+
</div>
|
|
263
|
+
</div>
|
|
264
|
+
|
|
265
|
+
{/* Content */}
|
|
266
|
+
<div className="flex-1 overflow-auto p-4 bg-gray-900 flex items-center justify-center min-h-[400px]">
|
|
267
|
+
{loadingUrl && (
|
|
268
|
+
<div className="text-center text-white">
|
|
269
|
+
<CircleNotch className="w-8 h-8 mx-auto mb-2 animate-spin text-[var(--cyan)]" />
|
|
270
|
+
<p className="text-sm text-gray-400">Loading file...</p>
|
|
271
|
+
</div>
|
|
272
|
+
)}
|
|
273
|
+
{urlError && (
|
|
274
|
+
<div className="text-center text-white max-w-md">
|
|
275
|
+
<File className="w-16 h-16 mx-auto mb-4 text-gray-500" />
|
|
276
|
+
<p className="text-lg font-medium mb-2">Failed to Load File</p>
|
|
277
|
+
<p className="text-sm text-gray-400">{urlError}</p>
|
|
278
|
+
</div>
|
|
279
|
+
)}
|
|
280
|
+
{signedUrl && !loadingUrl && !urlError && (
|
|
281
|
+
<>
|
|
282
|
+
{getPreviewType(previewFile) === 'image' && (
|
|
283
|
+
<img
|
|
284
|
+
src={signedUrl}
|
|
285
|
+
alt={previewFile.filename}
|
|
286
|
+
className="max-w-full max-h-[70vh] object-contain rounded-md"
|
|
287
|
+
/>
|
|
288
|
+
)}
|
|
289
|
+
{getPreviewType(previewFile) === 'video' && !videoError && (
|
|
290
|
+
<video
|
|
291
|
+
ref={videoRef}
|
|
292
|
+
src={signedUrl}
|
|
293
|
+
controls
|
|
294
|
+
autoPlay
|
|
295
|
+
className="w-full max-h-[70vh] rounded-md bg-black"
|
|
296
|
+
onError={() => setVideoError(true)}
|
|
297
|
+
>
|
|
298
|
+
Your browser does not support the video tag.
|
|
299
|
+
</video>
|
|
300
|
+
)}
|
|
301
|
+
{getPreviewType(previewFile) === 'video' && videoError && (
|
|
302
|
+
<div className="text-center text-white max-w-md">
|
|
303
|
+
<FileVideo className="w-16 h-16 mx-auto mb-4 text-gray-500" />
|
|
304
|
+
<p className="text-lg font-medium mb-2">Video Preview Not Available</p>
|
|
305
|
+
<p className="text-sm text-gray-400 mb-6">
|
|
306
|
+
This video format (.{previewFile.filename.split('.').pop()}) cannot be played directly in the browser.
|
|
307
|
+
</p>
|
|
308
|
+
<div className="flex gap-3 justify-center">
|
|
309
|
+
<a
|
|
310
|
+
href={signedUrl}
|
|
311
|
+
target="_blank"
|
|
312
|
+
rel="noopener noreferrer"
|
|
313
|
+
className="inline-flex items-center gap-2 px-4 py-2 bg-white text-gray-900 rounded-full text-sm font-medium hover:bg-gray-100 transition-colors"
|
|
314
|
+
>
|
|
315
|
+
<ArrowSquareOut className="w-4 h-4" />
|
|
316
|
+
Open in New Tab
|
|
317
|
+
</a>
|
|
318
|
+
<a
|
|
319
|
+
href={signedUrl}
|
|
320
|
+
download={previewFile.filename}
|
|
321
|
+
className="inline-flex items-center gap-2 px-4 py-2 bg-[var(--cyan)] text-gray-900 rounded-full text-sm font-medium hover:bg-[var(--cyan)]/90 transition-colors"
|
|
322
|
+
>
|
|
323
|
+
<Download className="w-4 h-4" />
|
|
324
|
+
Download
|
|
325
|
+
</a>
|
|
326
|
+
</div>
|
|
327
|
+
</div>
|
|
328
|
+
)}
|
|
329
|
+
{getPreviewType(previewFile) === 'pdf' && (
|
|
330
|
+
<iframe
|
|
331
|
+
src={signedUrl}
|
|
332
|
+
className="w-full h-[70vh] rounded-md border-0 bg-white"
|
|
333
|
+
title={previewFile.filename}
|
|
334
|
+
/>
|
|
335
|
+
)}
|
|
336
|
+
</>
|
|
337
|
+
)}
|
|
338
|
+
</div>
|
|
339
|
+
</div>
|
|
340
|
+
</div>
|
|
341
|
+
)}
|
|
342
|
+
</>
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
if (!showCard) {
|
|
346
|
+
return content
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return (
|
|
350
|
+
<Card>
|
|
351
|
+
<CardHeader className="pb-3">
|
|
352
|
+
<CardTitle className="text-base">{title}</CardTitle>
|
|
353
|
+
</CardHeader>
|
|
354
|
+
<CardContent>
|
|
355
|
+
{content}
|
|
356
|
+
</CardContent>
|
|
357
|
+
</Card>
|
|
358
|
+
)
|
|
359
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { cn } from '../lib/utils'
|
|
5
|
+
|
|
6
|
+
export interface MetricLabelProps extends React.HTMLAttributes<HTMLParagraphElement> {
|
|
7
|
+
/** The label text */
|
|
8
|
+
children: React.ReactNode
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* MetricLabel - A utility component for consistent metric labeling
|
|
13
|
+
*
|
|
14
|
+
* Uses the standardized pattern: text-xs uppercase tracking-wide text-muted-foreground
|
|
15
|
+
* Typically used above metric values in dashboards and cards.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* <MetricLabel>Monthly Revenue</MetricLabel>
|
|
19
|
+
* <p className="text-2xl font-bold">€5,000</p>
|
|
20
|
+
*/
|
|
21
|
+
const MetricLabel = React.forwardRef<HTMLParagraphElement, MetricLabelProps>(
|
|
22
|
+
({ className, children, ...props }, ref) => {
|
|
23
|
+
return (
|
|
24
|
+
<p
|
|
25
|
+
ref={ref}
|
|
26
|
+
className={cn(
|
|
27
|
+
'text-xs text-muted-foreground uppercase tracking-wide',
|
|
28
|
+
className
|
|
29
|
+
)}
|
|
30
|
+
{...props}
|
|
31
|
+
>
|
|
32
|
+
{children}
|
|
33
|
+
</p>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
MetricLabel.displayName = 'MetricLabel'
|
|
38
|
+
|
|
39
|
+
export interface MetricValueProps extends React.HTMLAttributes<HTMLParagraphElement> {
|
|
40
|
+
/** The value to display */
|
|
41
|
+
children: React.ReactNode
|
|
42
|
+
/** Size variant */
|
|
43
|
+
size?: 'default' | 'lg' | 'sm'
|
|
44
|
+
/** Highlight the value (uses brand cyan) */
|
|
45
|
+
highlight?: boolean
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* MetricValue - A utility component for consistent metric value display
|
|
50
|
+
*
|
|
51
|
+
* Pairs with MetricLabel for a complete metric display pattern.
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* <MetricLabel>Monthly Revenue</MetricLabel>
|
|
55
|
+
* <MetricValue highlight>€5,000</MetricValue>
|
|
56
|
+
*/
|
|
57
|
+
const MetricValue = React.forwardRef<HTMLParagraphElement, MetricValueProps>(
|
|
58
|
+
({ className, children, size = 'default', highlight = false, ...props }, ref) => {
|
|
59
|
+
return (
|
|
60
|
+
<p
|
|
61
|
+
ref={ref}
|
|
62
|
+
className={cn(
|
|
63
|
+
'font-bold mt-1',
|
|
64
|
+
size === 'lg' && 'text-3xl',
|
|
65
|
+
size === 'default' && 'text-2xl',
|
|
66
|
+
size === 'sm' && 'text-xl',
|
|
67
|
+
highlight && 'text-[#00e5cc]',
|
|
68
|
+
className
|
|
69
|
+
)}
|
|
70
|
+
{...props}
|
|
71
|
+
>
|
|
72
|
+
{children}
|
|
73
|
+
</p>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
MetricValue.displayName = 'MetricValue'
|
|
78
|
+
|
|
79
|
+
export interface MetricSubtextProps extends React.HTMLAttributes<HTMLParagraphElement> {
|
|
80
|
+
/** The subtext content */
|
|
81
|
+
children: React.ReactNode
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* MetricSubtext - A utility component for metric subtitles/descriptions
|
|
86
|
+
*
|
|
87
|
+
* @example
|
|
88
|
+
* <MetricLabel>Monthly Revenue</MetricLabel>
|
|
89
|
+
* <MetricValue>€5,000</MetricValue>
|
|
90
|
+
* <MetricSubtext>ARR: €60,000</MetricSubtext>
|
|
91
|
+
*/
|
|
92
|
+
const MetricSubtext = React.forwardRef<HTMLParagraphElement, MetricSubtextProps>(
|
|
93
|
+
({ className, children, ...props }, ref) => {
|
|
94
|
+
return (
|
|
95
|
+
<p
|
|
96
|
+
ref={ref}
|
|
97
|
+
className={cn(
|
|
98
|
+
'text-xs text-muted-foreground mt-1',
|
|
99
|
+
className
|
|
100
|
+
)}
|
|
101
|
+
{...props}
|
|
102
|
+
>
|
|
103
|
+
{children}
|
|
104
|
+
</p>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
)
|
|
108
|
+
MetricSubtext.displayName = 'MetricSubtext'
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Metric - A compound component combining Label, Value, and optional Subtext
|
|
112
|
+
*
|
|
113
|
+
* @example
|
|
114
|
+
* <Metric>
|
|
115
|
+
* <MetricLabel>Monthly Revenue</MetricLabel>
|
|
116
|
+
* <MetricValue highlight>€5,000</MetricValue>
|
|
117
|
+
* <MetricSubtext>ARR: €60,000</MetricSubtext>
|
|
118
|
+
* </Metric>
|
|
119
|
+
*/
|
|
120
|
+
const Metric = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
121
|
+
({ className, children, ...props }, ref) => {
|
|
122
|
+
return (
|
|
123
|
+
<div
|
|
124
|
+
ref={ref}
|
|
125
|
+
className={cn(className)}
|
|
126
|
+
{...props}
|
|
127
|
+
>
|
|
128
|
+
{children}
|
|
129
|
+
</div>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
)
|
|
133
|
+
Metric.displayName = 'Metric'
|
|
134
|
+
|
|
135
|
+
export { MetricLabel, MetricValue, MetricSubtext, Metric }
|