@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dilipod/ui",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "description": "Dilipod Design System - Shared UI components and styles",
5
5
  "author": "Dilipod <hello@dilipod.com>",
6
6
  "license": "MIT",
@@ -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 }