@moontra/moonui-pro 2.0.22 → 2.0.23
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 +2 -1
- package/src/__tests__/use-intersection-observer.test.tsx +216 -0
- package/src/__tests__/use-local-storage.test.tsx +174 -0
- package/src/__tests__/use-pro-access.test.tsx +183 -0
- package/src/components/advanced-chart/advanced-chart.test.tsx +281 -0
- package/src/components/advanced-chart/index.tsx +412 -0
- package/src/components/advanced-forms/index.tsx +431 -0
- package/src/components/animated-button/index.tsx +202 -0
- package/src/components/calendar/event-dialog.tsx +372 -0
- package/src/components/calendar/index.tsx +531 -0
- package/src/components/color-picker/index.tsx +434 -0
- package/src/components/dashboard/index.tsx +334 -0
- package/src/components/data-table/data-table.test.tsx +187 -0
- package/src/components/data-table/index.tsx +368 -0
- package/src/components/draggable-list/index.tsx +100 -0
- package/src/components/enhanced/button.tsx +360 -0
- package/src/components/enhanced/card.tsx +272 -0
- package/src/components/enhanced/dialog.tsx +248 -0
- package/src/components/enhanced/index.ts +3 -0
- package/src/components/error-boundary/index.tsx +111 -0
- package/src/components/file-upload/file-upload.test.tsx +242 -0
- package/src/components/file-upload/index.tsx +362 -0
- package/src/components/floating-action-button/index.tsx +209 -0
- package/src/components/github-stars/index.tsx +414 -0
- package/src/components/health-check/index.tsx +441 -0
- package/src/components/hover-card-3d/index.tsx +170 -0
- package/src/components/index.ts +76 -0
- package/src/components/kanban/index.tsx +436 -0
- package/src/components/lazy-component/index.tsx +342 -0
- package/src/components/magnetic-button/index.tsx +170 -0
- package/src/components/memory-efficient-data/index.tsx +352 -0
- package/src/components/optimized-image/index.tsx +427 -0
- package/src/components/performance-debugger/index.tsx +591 -0
- package/src/components/performance-monitor/index.tsx +775 -0
- package/src/components/pinch-zoom/index.tsx +172 -0
- package/src/components/rich-text-editor/index-old-backup.tsx +443 -0
- package/src/components/rich-text-editor/index.tsx +1537 -0
- package/src/components/rich-text-editor/slash-commands-extension.ts +220 -0
- package/src/components/rich-text-editor/slash-commands.css +35 -0
- package/src/components/rich-text-editor/table-styles.css +65 -0
- package/src/components/spotlight-card/index.tsx +194 -0
- package/src/components/swipeable-card/index.tsx +100 -0
- package/src/components/timeline/index.tsx +333 -0
- package/src/components/ui/animated-button.tsx +185 -0
- package/src/components/ui/avatar.tsx +135 -0
- package/src/components/ui/badge.tsx +225 -0
- package/src/components/ui/button.tsx +221 -0
- package/src/components/ui/card.tsx +141 -0
- package/src/components/ui/checkbox.tsx +256 -0
- package/src/components/ui/color-picker.tsx +95 -0
- package/src/components/ui/dialog.tsx +332 -0
- package/src/components/ui/dropdown-menu.tsx +200 -0
- package/src/components/ui/hover-card-3d.tsx +103 -0
- package/src/components/ui/index.ts +33 -0
- package/src/components/ui/input.tsx +219 -0
- package/src/components/ui/label.tsx +26 -0
- package/src/components/ui/magnetic-button.tsx +129 -0
- package/src/components/ui/popover.tsx +183 -0
- package/src/components/ui/select.tsx +273 -0
- package/src/components/ui/separator.tsx +140 -0
- package/src/components/ui/slider.tsx +351 -0
- package/src/components/ui/spotlight-card.tsx +119 -0
- package/src/components/ui/switch.tsx +83 -0
- package/src/components/ui/tabs.tsx +195 -0
- package/src/components/ui/textarea.tsx +25 -0
- package/src/components/ui/toast.tsx +313 -0
- package/src/components/ui/tooltip.tsx +152 -0
- package/src/components/virtual-list/index.tsx +369 -0
- package/src/hooks/use-chart.ts +205 -0
- package/src/hooks/use-data-table.ts +182 -0
- package/src/hooks/use-docs-pro-access.ts +13 -0
- package/src/hooks/use-license-check.ts +65 -0
- package/src/hooks/use-subscription.ts +19 -0
- package/src/index.ts +11 -0
- package/src/lib/micro-interactions.ts +255 -0
- package/src/lib/utils.ts +6 -0
- package/src/patterns/login-form/index.tsx +276 -0
- package/src/patterns/login-form/types.ts +67 -0
- package/src/setupTests.ts +41 -0
- package/src/styles/design-system.css +365 -0
- package/src/styles/index.css +4 -0
- package/src/styles/tailwind.css +6 -0
- package/src/styles/tokens.css +453 -0
- package/src/types/moonui.d.ts +22 -0
- package/src/use-intersection-observer.tsx +154 -0
- package/src/use-local-storage.tsx +71 -0
- package/src/use-paddle.ts +138 -0
- package/src/use-performance-optimizer.ts +379 -0
- package/src/use-pro-access.ts +141 -0
- package/src/use-scroll-animation.ts +221 -0
- package/src/use-subscription.ts +37 -0
- package/src/use-toast.ts +32 -0
- package/src/utils/chart-helpers.ts +257 -0
- package/src/utils/cn.ts +69 -0
- package/src/utils/data-processing.ts +151 -0
- package/src/utils/license-validator.tsx +183 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import React from 'react'
|
|
4
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './card'
|
|
5
|
+
import { Button } from './button'
|
|
6
|
+
import { Progress } from './progress'
|
|
7
|
+
import { Badge } from './badge'
|
|
8
|
+
import {
|
|
9
|
+
Upload,
|
|
10
|
+
File,
|
|
11
|
+
Image,
|
|
12
|
+
X,
|
|
13
|
+
Download,
|
|
14
|
+
FileText,
|
|
15
|
+
Video,
|
|
16
|
+
Music,
|
|
17
|
+
Archive,
|
|
18
|
+
AlertCircle,
|
|
19
|
+
CheckCircle2,
|
|
20
|
+
Loader2,
|
|
21
|
+
Lock,
|
|
22
|
+
Sparkles
|
|
23
|
+
} from 'lucide-react'
|
|
24
|
+
import { cn } from '../../lib/utils'
|
|
25
|
+
import { useDocsProAccess } from '@/components/docs/docs-pro-provider'
|
|
26
|
+
import { useSubscription } from '../../hooks/use-subscription'
|
|
27
|
+
|
|
28
|
+
export interface FileUploadProps {
|
|
29
|
+
accept?: string
|
|
30
|
+
multiple?: boolean
|
|
31
|
+
maxSize?: number // in MB
|
|
32
|
+
maxFiles?: number
|
|
33
|
+
onUpload?: (files: File[]) => Promise<void>
|
|
34
|
+
onRemove?: (file: File) => void
|
|
35
|
+
className?: string
|
|
36
|
+
disabled?: boolean
|
|
37
|
+
showPreview?: boolean
|
|
38
|
+
allowedTypes?: string[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface UploadedFile {
|
|
42
|
+
file: File
|
|
43
|
+
id: string
|
|
44
|
+
status: 'uploading' | 'success' | 'error'
|
|
45
|
+
progress: number
|
|
46
|
+
error?: string
|
|
47
|
+
url?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const getFileIcon = (type: string) => {
|
|
51
|
+
if (type.startsWith('image/')) return <Image className="h-4 w-4" />
|
|
52
|
+
if (type.startsWith('video/')) return <Video className="h-4 w-4" />
|
|
53
|
+
if (type.startsWith('audio/')) return <Music className="h-4 w-4" />
|
|
54
|
+
if (type.includes('pdf')) return <FileText className="h-4 w-4" />
|
|
55
|
+
if (type.includes('zip') || type.includes('rar')) return <Archive className="h-4 w-4" />
|
|
56
|
+
return <File className="h-4 w-4" />
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const formatFileSize = (bytes: number): string => {
|
|
60
|
+
if (bytes === 0) return '0 Bytes'
|
|
61
|
+
const k = 1024
|
|
62
|
+
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
|
63
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
64
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function FileUpload({
|
|
68
|
+
accept = '*',
|
|
69
|
+
multiple = false,
|
|
70
|
+
maxSize = 10, // 10MB default
|
|
71
|
+
maxFiles = 5,
|
|
72
|
+
onUpload,
|
|
73
|
+
onRemove,
|
|
74
|
+
className,
|
|
75
|
+
disabled = false,
|
|
76
|
+
showPreview = true,
|
|
77
|
+
allowedTypes = []
|
|
78
|
+
}: FileUploadProps) {
|
|
79
|
+
// Check if we're in docs mode or have pro access
|
|
80
|
+
const docsProAccess = useDocsProAccess()
|
|
81
|
+
const { hasProAccess, isLoading } = useSubscription()
|
|
82
|
+
|
|
83
|
+
// In docs mode, always show the component
|
|
84
|
+
const canShowComponent = docsProAccess.isDocsMode || hasProAccess
|
|
85
|
+
|
|
86
|
+
// If not in docs mode and no pro access, show upgrade prompt
|
|
87
|
+
if (!docsProAccess.isDocsMode && !isLoading && !hasProAccess) {
|
|
88
|
+
return (
|
|
89
|
+
<Card className={cn("w-full", className)}>
|
|
90
|
+
<CardContent className="py-12 text-center">
|
|
91
|
+
<div className="max-w-md mx-auto space-y-4">
|
|
92
|
+
<div className="rounded-full bg-purple-100 dark:bg-purple-900/30 p-3 w-fit mx-auto">
|
|
93
|
+
<Lock className="h-6 w-6 text-purple-600 dark:text-purple-400" />
|
|
94
|
+
</div>
|
|
95
|
+
<div>
|
|
96
|
+
<h3 className="font-semibold text-lg mb-2">Pro Feature</h3>
|
|
97
|
+
<p className="text-muted-foreground text-sm mb-4">
|
|
98
|
+
File Upload is available exclusively to MoonUI Pro subscribers.
|
|
99
|
+
</p>
|
|
100
|
+
<div className="flex gap-3 justify-center">
|
|
101
|
+
<a href="/pricing">
|
|
102
|
+
<Button size="sm">
|
|
103
|
+
<Sparkles className="mr-2 h-4 w-4" />
|
|
104
|
+
Upgrade to Pro
|
|
105
|
+
</Button>
|
|
106
|
+
</a>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
</CardContent>
|
|
111
|
+
</Card>
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const [files, setFiles] = React.useState<UploadedFile[]>([])
|
|
116
|
+
const [isDragOver, setIsDragOver] = React.useState(false)
|
|
117
|
+
const [isUploading, setIsUploading] = React.useState(false)
|
|
118
|
+
const fileInputRef = React.useRef<HTMLInputElement>(null)
|
|
119
|
+
|
|
120
|
+
const validateFile = (file: File): string | null => {
|
|
121
|
+
// Check file size
|
|
122
|
+
if (file.size > maxSize * 1024 * 1024) {
|
|
123
|
+
return `File size must be less than ${maxSize}MB`
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Check file type
|
|
127
|
+
if (allowedTypes.length > 0 && !allowedTypes.includes(file.type)) {
|
|
128
|
+
return `File type ${file.type} is not allowed`
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return null
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const handleFileSelect = async (selectedFiles: FileList | null) => {
|
|
135
|
+
if (!selectedFiles || disabled) return
|
|
136
|
+
|
|
137
|
+
const fileArray = Array.from(selectedFiles)
|
|
138
|
+
|
|
139
|
+
// Check max files limit
|
|
140
|
+
if (files.length + fileArray.length > maxFiles) {
|
|
141
|
+
alert(`Maximum ${maxFiles} files allowed`)
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const validFiles: UploadedFile[] = []
|
|
146
|
+
const invalidFiles: string[] = []
|
|
147
|
+
|
|
148
|
+
fileArray.forEach(file => {
|
|
149
|
+
const error = validateFile(file)
|
|
150
|
+
if (error) {
|
|
151
|
+
invalidFiles.push(`${file.name}: ${error}`)
|
|
152
|
+
} else {
|
|
153
|
+
validFiles.push({
|
|
154
|
+
file,
|
|
155
|
+
id: Math.random().toString(36).substr(2, 9),
|
|
156
|
+
status: 'uploading',
|
|
157
|
+
progress: 0
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
if (invalidFiles.length > 0) {
|
|
163
|
+
alert(invalidFiles.join('\n'))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (validFiles.length === 0) return
|
|
167
|
+
|
|
168
|
+
setFiles(prev => [...prev, ...validFiles])
|
|
169
|
+
setIsUploading(true)
|
|
170
|
+
|
|
171
|
+
// Simulate upload process
|
|
172
|
+
if (onUpload) {
|
|
173
|
+
try {
|
|
174
|
+
await onUpload(validFiles.map(f => f.file))
|
|
175
|
+
|
|
176
|
+
// Update all files to success
|
|
177
|
+
setFiles(prev => prev.map(f =>
|
|
178
|
+
validFiles.find(vf => vf.id === f.id)
|
|
179
|
+
? { ...f, status: 'success' as const, progress: 100 }
|
|
180
|
+
: f
|
|
181
|
+
))
|
|
182
|
+
} catch (error) {
|
|
183
|
+
// Update all files to error
|
|
184
|
+
setFiles(prev => prev.map(f =>
|
|
185
|
+
validFiles.find(vf => vf.id === f.id)
|
|
186
|
+
? { ...f, status: 'error' as const, error: 'Upload failed' }
|
|
187
|
+
: f
|
|
188
|
+
))
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
// Simulate progress
|
|
192
|
+
for (const validFile of validFiles) {
|
|
193
|
+
const interval = setInterval(() => {
|
|
194
|
+
setFiles(prev => prev.map(f =>
|
|
195
|
+
f.id === validFile.id && f.progress < 100
|
|
196
|
+
? { ...f, progress: f.progress + 10 }
|
|
197
|
+
: f
|
|
198
|
+
))
|
|
199
|
+
}, 100)
|
|
200
|
+
|
|
201
|
+
setTimeout(() => {
|
|
202
|
+
clearInterval(interval)
|
|
203
|
+
setFiles(prev => prev.map(f =>
|
|
204
|
+
f.id === validFile.id
|
|
205
|
+
? { ...f, status: 'success' as const, progress: 100 }
|
|
206
|
+
: f
|
|
207
|
+
))
|
|
208
|
+
}, 1000)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
setIsUploading(false)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const handleRemove = (fileToRemove: UploadedFile) => {
|
|
216
|
+
setFiles(prev => prev.filter(f => f.id !== fileToRemove.id))
|
|
217
|
+
if (onRemove) {
|
|
218
|
+
onRemove(fileToRemove.file)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
|
223
|
+
e.preventDefault()
|
|
224
|
+
setIsDragOver(false)
|
|
225
|
+
handleFileSelect(e.dataTransfer.files)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
|
229
|
+
e.preventDefault()
|
|
230
|
+
setIsDragOver(true)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
|
|
234
|
+
e.preventDefault()
|
|
235
|
+
setIsDragOver(false)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const handleClick = () => {
|
|
239
|
+
if (!disabled) {
|
|
240
|
+
fileInputRef.current?.click()
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return (
|
|
245
|
+
<Card className={cn("w-full", className)}>
|
|
246
|
+
<CardHeader>
|
|
247
|
+
<CardTitle className="flex items-center gap-2">
|
|
248
|
+
<Upload className="h-5 w-5" />
|
|
249
|
+
File Upload
|
|
250
|
+
</CardTitle>
|
|
251
|
+
<CardDescription>
|
|
252
|
+
{multiple ? `Upload up to ${maxFiles} files` : 'Upload a file'} (max {maxSize}MB each)
|
|
253
|
+
</CardDescription>
|
|
254
|
+
</CardHeader>
|
|
255
|
+
<CardContent className="space-y-4">
|
|
256
|
+
{/* Drop Zone */}
|
|
257
|
+
<div
|
|
258
|
+
className={cn(
|
|
259
|
+
"border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors",
|
|
260
|
+
isDragOver ? "border-primary bg-primary/5" : "border-muted-foreground/25",
|
|
261
|
+
disabled && "cursor-not-allowed opacity-50"
|
|
262
|
+
)}
|
|
263
|
+
onDrop={handleDrop}
|
|
264
|
+
onDragOver={handleDragOver}
|
|
265
|
+
onDragLeave={handleDragLeave}
|
|
266
|
+
onClick={handleClick}
|
|
267
|
+
>
|
|
268
|
+
<Upload className="h-10 w-10 mx-auto mb-4 text-muted-foreground" />
|
|
269
|
+
<p className="text-sm text-muted-foreground mb-2">
|
|
270
|
+
Drag and drop files here, or click to select
|
|
271
|
+
</p>
|
|
272
|
+
<p className="text-xs text-muted-foreground">
|
|
273
|
+
{accept === '*' ? 'Any file type' : `Accepted types: ${accept}`}
|
|
274
|
+
</p>
|
|
275
|
+
</div>
|
|
276
|
+
|
|
277
|
+
{/* File Input */}
|
|
278
|
+
<input
|
|
279
|
+
ref={fileInputRef}
|
|
280
|
+
type="file"
|
|
281
|
+
accept={accept}
|
|
282
|
+
multiple={multiple}
|
|
283
|
+
onChange={(e) => handleFileSelect(e.target.files)}
|
|
284
|
+
className="hidden"
|
|
285
|
+
disabled={disabled}
|
|
286
|
+
/>
|
|
287
|
+
|
|
288
|
+
{/* File List */}
|
|
289
|
+
{files.length > 0 && (
|
|
290
|
+
<div className="space-y-2">
|
|
291
|
+
<h4 className="text-sm font-medium">Uploaded Files ({files.length})</h4>
|
|
292
|
+
{files.map((uploadedFile) => (
|
|
293
|
+
<div key={uploadedFile.id} className="flex items-center gap-3 p-3 border rounded-lg">
|
|
294
|
+
<div className="flex-shrink-0">
|
|
295
|
+
{getFileIcon(uploadedFile.file.type)}
|
|
296
|
+
</div>
|
|
297
|
+
|
|
298
|
+
<div className="flex-1 min-w-0">
|
|
299
|
+
<div className="flex items-center justify-between">
|
|
300
|
+
<p className="text-sm font-medium truncate">{uploadedFile.file.name}</p>
|
|
301
|
+
<Badge variant={uploadedFile.status === 'success' ? 'success' : uploadedFile.status === 'error' ? 'destructive' : 'secondary'}>
|
|
302
|
+
{uploadedFile.status === 'uploading' && <Loader2 className="h-3 w-3 mr-1 animate-spin" />}
|
|
303
|
+
{uploadedFile.status === 'success' && <CheckCircle2 className="h-3 w-3 mr-1" />}
|
|
304
|
+
{uploadedFile.status === 'error' && <AlertCircle className="h-3 w-3 mr-1" />}
|
|
305
|
+
{uploadedFile.status}
|
|
306
|
+
</Badge>
|
|
307
|
+
</div>
|
|
308
|
+
|
|
309
|
+
<div className="flex items-center justify-between mt-1">
|
|
310
|
+
<p className="text-xs text-muted-foreground">
|
|
311
|
+
{formatFileSize(uploadedFile.file.size)}
|
|
312
|
+
</p>
|
|
313
|
+
{uploadedFile.status === 'uploading' && (
|
|
314
|
+
<span className="text-xs text-muted-foreground">
|
|
315
|
+
{uploadedFile.progress}%
|
|
316
|
+
</span>
|
|
317
|
+
)}
|
|
318
|
+
</div>
|
|
319
|
+
|
|
320
|
+
{uploadedFile.status === 'uploading' && (
|
|
321
|
+
<Progress value={uploadedFile.progress} className="mt-2" />
|
|
322
|
+
)}
|
|
323
|
+
|
|
324
|
+
{uploadedFile.error && (
|
|
325
|
+
<p className="text-xs text-destructive mt-1">{uploadedFile.error}</p>
|
|
326
|
+
)}
|
|
327
|
+
</div>
|
|
328
|
+
|
|
329
|
+
<div className="flex items-center gap-1">
|
|
330
|
+
{uploadedFile.status === 'success' && showPreview && uploadedFile.file.type.startsWith('image/') && (
|
|
331
|
+
<Button variant="ghost" size="sm">
|
|
332
|
+
<Download className="h-4 w-4" />
|
|
333
|
+
</Button>
|
|
334
|
+
)}
|
|
335
|
+
|
|
336
|
+
<Button
|
|
337
|
+
variant="ghost"
|
|
338
|
+
size="sm"
|
|
339
|
+
onClick={() => handleRemove(uploadedFile)}
|
|
340
|
+
disabled={uploadedFile.status === 'uploading'}
|
|
341
|
+
>
|
|
342
|
+
<X className="h-4 w-4" />
|
|
343
|
+
</Button>
|
|
344
|
+
</div>
|
|
345
|
+
</div>
|
|
346
|
+
))}
|
|
347
|
+
</div>
|
|
348
|
+
)}
|
|
349
|
+
|
|
350
|
+
{/* Upload Status */}
|
|
351
|
+
{isUploading && (
|
|
352
|
+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
353
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
354
|
+
Uploading files...
|
|
355
|
+
</div>
|
|
356
|
+
)}
|
|
357
|
+
</CardContent>
|
|
358
|
+
</Card>
|
|
359
|
+
)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export default FileUpload
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import React, { useState } from "react"
|
|
4
|
+
import { motion, AnimatePresence } from "framer-motion"
|
|
5
|
+
import { Plus, X, Lock, Sparkles } from "lucide-react"
|
|
6
|
+
import { cn } from "../../lib/utils"
|
|
7
|
+
import { Card, CardContent } from "../ui/card"
|
|
8
|
+
import { Button } from "../ui/button"
|
|
9
|
+
import { useSubscription } from "../../hooks/use-subscription"
|
|
10
|
+
|
|
11
|
+
export interface FloatingActionButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
12
|
+
actions?: Array<{
|
|
13
|
+
icon: React.ReactNode
|
|
14
|
+
label: string
|
|
15
|
+
onClick: () => void
|
|
16
|
+
}>
|
|
17
|
+
position?: "bottom-right" | "bottom-left" | "top-right" | "top-left"
|
|
18
|
+
size?: "sm" | "default" | "lg"
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const FloatingActionButtonInternal = React.forwardRef<HTMLButtonElement, FloatingActionButtonProps>(
|
|
22
|
+
({
|
|
23
|
+
actions = [],
|
|
24
|
+
position = "bottom-right",
|
|
25
|
+
size = "default",
|
|
26
|
+
className,
|
|
27
|
+
children,
|
|
28
|
+
onClick,
|
|
29
|
+
...props
|
|
30
|
+
}, ref) => {
|
|
31
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
32
|
+
|
|
33
|
+
const sizeClasses = {
|
|
34
|
+
sm: "h-10 w-10",
|
|
35
|
+
default: "h-12 w-12",
|
|
36
|
+
lg: "h-14 w-14"
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const positionClasses = {
|
|
40
|
+
"bottom-right": "bottom-4 right-4",
|
|
41
|
+
"bottom-left": "bottom-4 left-4",
|
|
42
|
+
"top-right": "top-4 right-4",
|
|
43
|
+
"top-left": "top-4 left-4"
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const actionPositions = {
|
|
47
|
+
"bottom-right": { x: -60, y: 0 },
|
|
48
|
+
"bottom-left": { x: 60, y: 0 },
|
|
49
|
+
"top-right": { x: -60, y: 0 },
|
|
50
|
+
"top-left": { x: 60, y: 0 }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const handleMainClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
|
54
|
+
if (actions.length > 0) {
|
|
55
|
+
setIsOpen(!isOpen)
|
|
56
|
+
} else if (onClick) {
|
|
57
|
+
onClick(e)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div className={cn("fixed z-50", positionClasses[position])}>
|
|
63
|
+
{/* Action buttons */}
|
|
64
|
+
<AnimatePresence>
|
|
65
|
+
{isOpen && actions.length > 0 && (
|
|
66
|
+
<div className="absolute">
|
|
67
|
+
{actions.map((action, index) => (
|
|
68
|
+
<motion.div
|
|
69
|
+
key={index}
|
|
70
|
+
initial={{
|
|
71
|
+
scale: 0,
|
|
72
|
+
opacity: 0,
|
|
73
|
+
x: 0,
|
|
74
|
+
y: 0
|
|
75
|
+
}}
|
|
76
|
+
animate={{
|
|
77
|
+
scale: 1,
|
|
78
|
+
opacity: 1,
|
|
79
|
+
x: actionPositions[position].x,
|
|
80
|
+
y: actionPositions[position].y * (index + 1) - 10
|
|
81
|
+
}}
|
|
82
|
+
exit={{
|
|
83
|
+
scale: 0,
|
|
84
|
+
opacity: 0,
|
|
85
|
+
x: 0,
|
|
86
|
+
y: 0
|
|
87
|
+
}}
|
|
88
|
+
transition={{
|
|
89
|
+
delay: index * 0.05,
|
|
90
|
+
type: "spring",
|
|
91
|
+
stiffness: 200,
|
|
92
|
+
damping: 15
|
|
93
|
+
}}
|
|
94
|
+
className="absolute flex items-center gap-2"
|
|
95
|
+
style={{
|
|
96
|
+
transform: `translate(${position.includes('right') ? '0' : '0'}, ${position.includes('bottom') ? '0' : '0'})`
|
|
97
|
+
}}
|
|
98
|
+
>
|
|
99
|
+
{/* Label */}
|
|
100
|
+
<motion.div
|
|
101
|
+
initial={{ opacity: 0, scale: 0.8 }}
|
|
102
|
+
animate={{ opacity: 1, scale: 1 }}
|
|
103
|
+
exit={{ opacity: 0, scale: 0.8 }}
|
|
104
|
+
transition={{ delay: (index + 1) * 0.05 }}
|
|
105
|
+
className={cn(
|
|
106
|
+
"px-2 py-1 bg-background/90 backdrop-blur-sm text-sm font-medium rounded-md shadow-sm border whitespace-nowrap",
|
|
107
|
+
position.includes('right') ? 'mr-2' : 'ml-2'
|
|
108
|
+
)}
|
|
109
|
+
>
|
|
110
|
+
{action.label}
|
|
111
|
+
</motion.div>
|
|
112
|
+
|
|
113
|
+
{/* Action button */}
|
|
114
|
+
<button
|
|
115
|
+
onClick={() => {
|
|
116
|
+
action.onClick()
|
|
117
|
+
setIsOpen(false)
|
|
118
|
+
}}
|
|
119
|
+
className={cn(
|
|
120
|
+
"inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg hover:bg-primary/90 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
|
121
|
+
sizeClasses[size]
|
|
122
|
+
)}
|
|
123
|
+
>
|
|
124
|
+
{action.icon}
|
|
125
|
+
</button>
|
|
126
|
+
</motion.div>
|
|
127
|
+
))}
|
|
128
|
+
</div>
|
|
129
|
+
)}
|
|
130
|
+
</AnimatePresence>
|
|
131
|
+
|
|
132
|
+
{/* Main FAB */}
|
|
133
|
+
<motion.button
|
|
134
|
+
ref={ref}
|
|
135
|
+
className={cn(
|
|
136
|
+
"inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg hover:bg-primary/90 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
|
137
|
+
sizeClasses[size],
|
|
138
|
+
className
|
|
139
|
+
)}
|
|
140
|
+
onClick={handleMainClick}
|
|
141
|
+
whileHover={{ scale: 1.1 }}
|
|
142
|
+
whileTap={{ scale: 0.9 }}
|
|
143
|
+
animate={{ rotate: isOpen ? 45 : 0 }}
|
|
144
|
+
transition={{ duration: 0.2 }}
|
|
145
|
+
{...props}
|
|
146
|
+
>
|
|
147
|
+
{children || (actions.length > 0 ? <Plus className="h-5 w-5" /> : <Plus className="h-5 w-5" />)}
|
|
148
|
+
</motion.button>
|
|
149
|
+
|
|
150
|
+
{/* Backdrop */}
|
|
151
|
+
<AnimatePresence>
|
|
152
|
+
{isOpen && (
|
|
153
|
+
<motion.div
|
|
154
|
+
initial={{ opacity: 0 }}
|
|
155
|
+
animate={{ opacity: 1 }}
|
|
156
|
+
exit={{ opacity: 0 }}
|
|
157
|
+
className="fixed inset-0 z-[-1]"
|
|
158
|
+
onClick={() => setIsOpen(false)}
|
|
159
|
+
/>
|
|
160
|
+
)}
|
|
161
|
+
</AnimatePresence>
|
|
162
|
+
</div>
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
FloatingActionButtonInternal.displayName = "FloatingActionButtonInternal"
|
|
168
|
+
|
|
169
|
+
export const FloatingActionButton = React.forwardRef<HTMLButtonElement, FloatingActionButtonProps>(
|
|
170
|
+
({ className, ...props }, ref) => {
|
|
171
|
+
// Check if we're in docs mode or have pro access
|
|
172
|
+
const docsProAccess = { hasAccess: true } // Pro access assumed in package
|
|
173
|
+
const { hasProAccess, isLoading } = useSubscription()
|
|
174
|
+
|
|
175
|
+
// In docs mode, always show the component
|
|
176
|
+
const canShowComponent = docsProAccess.isDocsMode || hasProAccess
|
|
177
|
+
|
|
178
|
+
// If not in docs mode and no pro access, show upgrade prompt
|
|
179
|
+
if (!docsProAccess.isDocsMode && !isLoading && !hasProAccess) {
|
|
180
|
+
return (
|
|
181
|
+
<Card className={cn("w-fit", className)}>
|
|
182
|
+
<CardContent className="py-6 text-center">
|
|
183
|
+
<div className="space-y-4">
|
|
184
|
+
<div className="rounded-full bg-purple-100 dark:bg-purple-900/30 p-3 w-fit mx-auto">
|
|
185
|
+
<Lock className="h-6 w-6 text-purple-600 dark:text-purple-400" />
|
|
186
|
+
</div>
|
|
187
|
+
<div>
|
|
188
|
+
<h3 className="font-semibold text-sm mb-2">Pro Feature</h3>
|
|
189
|
+
<p className="text-muted-foreground text-xs mb-4">
|
|
190
|
+
Floating Action Button is available exclusively to MoonUI Pro subscribers.
|
|
191
|
+
</p>
|
|
192
|
+
<a href="/pricing">
|
|
193
|
+
<Button size="sm">
|
|
194
|
+
<Sparkles className="mr-2 h-4 w-4" />
|
|
195
|
+
Upgrade to Pro
|
|
196
|
+
</Button>
|
|
197
|
+
</a>
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
</CardContent>
|
|
201
|
+
</Card>
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return <FloatingActionButtonInternal className={className} ref={ref} {...props} />
|
|
206
|
+
}
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
FloatingActionButton.displayName = "FloatingActionButton"
|