diff options
Diffstat (limited to 'frontend/components')
| -rw-r--r-- | frontend/components/AuthButton.tsx | 31 | ||||
| -rw-r--r-- | frontend/components/FileTable.tsx | 179 | ||||
| -rw-r--r-- | frontend/components/FileUpload.tsx | 262 | ||||
| -rw-r--r-- | frontend/components/ui/badge.tsx | 46 | ||||
| -rw-r--r-- | frontend/components/ui/button.tsx | 59 | ||||
| -rw-r--r-- | frontend/components/ui/checkbox.tsx | 47 | ||||
| -rw-r--r-- | frontend/components/ui/dialog.tsx | 143 | ||||
| -rw-r--r-- | frontend/components/ui/dropdown-menu.tsx | 257 | ||||
| -rw-r--r-- | frontend/components/ui/input.tsx | 21 | ||||
| -rw-r--r-- | frontend/components/ui/label.tsx | 24 | ||||
| -rw-r--r-- | frontend/components/ui/progress.tsx | 31 | ||||
| -rw-r--r-- | frontend/components/ui/table.tsx | 116 | ||||
| -rw-r--r-- | frontend/components/ui/toast.tsx | 129 | ||||
| -rw-r--r-- | frontend/components/ui/toaster.tsx | 35 |
14 files changed, 908 insertions, 472 deletions
diff --git a/frontend/components/AuthButton.tsx b/frontend/components/AuthButton.tsx deleted file mode 100644 index 05c493c..0000000 --- a/frontend/components/AuthButton.tsx +++ /dev/null | |||
| @@ -1,31 +0,0 @@ | |||
| 1 | import { Auth_get_user, Auth_tinyauth_public_endpoint } from '@/lib/auth' | ||
| 2 | |||
| 3 | export default async function AuthButton() { | ||
| 4 | const user = await Auth_get_user() | ||
| 5 | const authEndpoint = Auth_tinyauth_public_endpoint() | ||
| 6 | |||
| 7 | if (user.isLoggedIn) { | ||
| 8 | return ( | ||
| 9 | <div className="flex items-center gap-4"> | ||
| 10 | <span className="text-sm text-gray-600 dark:text-gray-300"> | ||
| 11 | {user.email} | ||
| 12 | </span> | ||
| 13 | <a | ||
| 14 | href={authEndpoint} | ||
| 15 | className="px-3 py-1 text-sm bg-red-600 hover:bg-red-700 text-white rounded transition-colors" | ||
| 16 | > | ||
| 17 | Logout | ||
| 18 | </a> | ||
| 19 | </div> | ||
| 20 | ) | ||
| 21 | } | ||
| 22 | |||
| 23 | return ( | ||
| 24 | <a | ||
| 25 | href={authEndpoint} | ||
| 26 | className="px-3 py-1 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors" | ||
| 27 | > | ||
| 28 | Login | ||
| 29 | </a> | ||
| 30 | ) | ||
| 31 | } \ No newline at end of file | ||
diff --git a/frontend/components/FileTable.tsx b/frontend/components/FileTable.tsx deleted file mode 100644 index 97660f3..0000000 --- a/frontend/components/FileTable.tsx +++ /dev/null | |||
| @@ -1,179 +0,0 @@ | |||
| 1 | 'use client' | ||
| 2 | |||
| 3 | import { useState } from 'react' | ||
| 4 | import { DriveLsEntry } from '@/lib/drive_types' | ||
| 5 | import { Drive_basename } from '@/lib/drive_shared' | ||
| 6 | import { formatSize } from '@/lib/utils' | ||
| 7 | import Link from 'next/link' | ||
| 8 | |||
| 9 | interface FileTableEntry extends DriveLsEntry { | ||
| 10 | isParent?: boolean | ||
| 11 | parentPath?: string | ||
| 12 | } | ||
| 13 | |||
| 14 | interface FileTableProps { | ||
| 15 | entries: DriveLsEntry[] | ||
| 16 | currentPath?: string | ||
| 17 | showParent?: boolean | ||
| 18 | parentPath?: string | ||
| 19 | onSelectedFilesChange?: (selectedFiles: string[]) => void | ||
| 20 | } | ||
| 21 | |||
| 22 | export default function FileTable({ | ||
| 23 | entries, | ||
| 24 | currentPath = '', | ||
| 25 | showParent = false, | ||
| 26 | parentPath, | ||
| 27 | onSelectedFilesChange | ||
| 28 | }: FileTableProps) { | ||
| 29 | const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set()) | ||
| 30 | |||
| 31 | // Create entries with optional parent directory at top | ||
| 32 | const allEntries: FileTableEntry[] = [] | ||
| 33 | if (showParent && parentPath !== undefined) { | ||
| 34 | allEntries.push({ | ||
| 35 | path: '(parent)', | ||
| 36 | type: 'dir' as const, | ||
| 37 | lastmod: 0, | ||
| 38 | blob: null, | ||
| 39 | size: null, | ||
| 40 | author: '', | ||
| 41 | isParent: true, | ||
| 42 | parentPath | ||
| 43 | }) | ||
| 44 | } | ||
| 45 | |||
| 46 | // Sort entries: directories first, then files, both alphabetically | ||
| 47 | const sortedEntries = entries.sort((a, b) => { | ||
| 48 | // First sort by type (directories before files) | ||
| 49 | if (a.type !== b.type) { | ||
| 50 | return a.type === 'dir' ? -1 : 1 | ||
| 51 | } | ||
| 52 | // Then sort alphabetically by path | ||
| 53 | return a.path.localeCompare(b.path) | ||
| 54 | }) | ||
| 55 | |||
| 56 | allEntries.push(...sortedEntries) | ||
| 57 | |||
| 58 | const handleFileSelection = (filePath: string, isSelected: boolean) => { | ||
| 59 | const newSelectedFiles = new Set(selectedFiles) | ||
| 60 | if (isSelected) { | ||
| 61 | newSelectedFiles.add(filePath) | ||
| 62 | } else { | ||
| 63 | newSelectedFiles.delete(filePath) | ||
| 64 | } | ||
| 65 | setSelectedFiles(newSelectedFiles) | ||
| 66 | onSelectedFilesChange?.(Array.from(newSelectedFiles)) | ||
| 67 | } | ||
| 68 | |||
| 69 | const handleSelectAll = (isSelected: boolean) => { | ||
| 70 | if (isSelected) { | ||
| 71 | // Select all files (not directories or parent) | ||
| 72 | const fileEntries = allEntries.filter(entry => | ||
| 73 | entry.type === 'file' && !entry.isParent | ||
| 74 | ) | ||
| 75 | const newSelectedFiles = new Set(fileEntries.map(entry => entry.path)) | ||
| 76 | setSelectedFiles(newSelectedFiles) | ||
| 77 | onSelectedFilesChange?.(Array.from(newSelectedFiles)) | ||
| 78 | } else { | ||
| 79 | setSelectedFiles(new Set()) | ||
| 80 | onSelectedFilesChange?.([]) | ||
| 81 | } | ||
| 82 | } | ||
| 83 | |||
| 84 | const selectableFiles = allEntries.filter(entry => | ||
| 85 | entry.type === 'file' && !entry.isParent | ||
| 86 | ) | ||
| 87 | const allFilesSelected = selectableFiles.length > 0 && | ||
| 88 | selectableFiles.every(entry => selectedFiles.has(entry.path)) | ||
| 89 | |||
| 90 | return ( | ||
| 91 | <div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden"> | ||
| 92 | <div className="overflow-x-auto"> | ||
| 93 | <table className="w-full"> | ||
| 94 | <thead className="bg-gray-50 dark:bg-gray-700"> | ||
| 95 | <tr> | ||
| 96 | <th className="px-4 py-3 text-left"> | ||
| 97 | <input | ||
| 98 | type="checkbox" | ||
| 99 | checked={allFilesSelected} | ||
| 100 | onChange={(e) => handleSelectAll(e.target.checked)} | ||
| 101 | className="rounded border-gray-300 text-blue-600 focus:ring-blue-500" | ||
| 102 | disabled={selectableFiles.length === 0} | ||
| 103 | /> | ||
| 104 | </th> | ||
| 105 | <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"> | ||
| 106 | Name | ||
| 107 | </th> | ||
| 108 | <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"> | ||
| 109 | Size | ||
| 110 | </th> | ||
| 111 | <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"> | ||
| 112 | Author | ||
| 113 | </th> | ||
| 114 | <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"> | ||
| 115 | Modified | ||
| 116 | </th> | ||
| 117 | </tr> | ||
| 118 | </thead> | ||
| 119 | <tbody className="divide-y divide-gray-200 dark:divide-gray-600"> | ||
| 120 | {allEntries.map((entry) => ( | ||
| 121 | <tr | ||
| 122 | key={entry.path} | ||
| 123 | className="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" | ||
| 124 | > | ||
| 125 | <td className="px-4 py-4 whitespace-nowrap"> | ||
| 126 | {entry.type === 'file' && !entry.isParent ? ( | ||
| 127 | <input | ||
| 128 | type="checkbox" | ||
| 129 | checked={selectedFiles.has(entry.path)} | ||
| 130 | onChange={(e) => handleFileSelection(entry.path, e.target.checked)} | ||
| 131 | className="rounded border-gray-300 text-blue-600 focus:ring-blue-500" | ||
| 132 | /> | ||
| 133 | ) : ( | ||
| 134 | <div className="w-4 h-4" /> // Placeholder to maintain alignment | ||
| 135 | )} | ||
| 136 | </td> | ||
| 137 | <td className="px-4 py-4 whitespace-nowrap"> | ||
| 138 | <div className="flex items-center"> | ||
| 139 | <div className="flex-shrink-0 h-5 w-5 mr-3"> | ||
| 140 | {entry.type === 'dir' ? ( | ||
| 141 | <div className="h-5 w-5 text-blue-500">📁</div> | ||
| 142 | ) : ( | ||
| 143 | <div className="h-5 w-5 text-gray-400">📄</div> | ||
| 144 | )} | ||
| 145 | </div> | ||
| 146 | {entry.type === 'dir' ? ( | ||
| 147 | <Link | ||
| 148 | href={entry.isParent ? entry.parentPath! : `/drive${entry.path}`} | ||
| 149 | className="text-sm font-medium text-blue-600 dark:text-blue-400 hover:underline" | ||
| 150 | > | ||
| 151 | {entry.isParent ? '(parent)' : Drive_basename(entry.path)} | ||
| 152 | </Link> | ||
| 153 | ) : ( | ||
| 154 | <Link | ||
| 155 | href={`/blob/${entry.blob}?filename=${encodeURIComponent(Drive_basename(entry.path))}`} | ||
| 156 | className="text-sm font-medium text-blue-600 dark:text-blue-400 hover:underline" | ||
| 157 | > | ||
| 158 | {Drive_basename(entry.path)} | ||
| 159 | </Link> | ||
| 160 | )} | ||
| 161 | </div> | ||
| 162 | </td> | ||
| 163 | <td className="px-4 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-300"> | ||
| 164 | {formatSize(entry.size)} | ||
| 165 | </td> | ||
| 166 | <td className="px-4 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-300"> | ||
| 167 | {entry.author} | ||
| 168 | </td> | ||
| 169 | <td className="px-4 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-300"> | ||
| 170 | {entry.lastmod > 0 ? new Date(entry.lastmod * 1000).toLocaleString() : ''} | ||
| 171 | </td> | ||
| 172 | </tr> | ||
| 173 | ))} | ||
| 174 | </tbody> | ||
| 175 | </table> | ||
| 176 | </div> | ||
| 177 | </div> | ||
| 178 | ) | ||
| 179 | } \ No newline at end of file | ||
diff --git a/frontend/components/FileUpload.tsx b/frontend/components/FileUpload.tsx deleted file mode 100644 index 8fbb919..0000000 --- a/frontend/components/FileUpload.tsx +++ /dev/null | |||
| @@ -1,262 +0,0 @@ | |||
| 1 | 'use client' | ||
| 2 | |||
| 3 | import { useState, useRef } from 'react' | ||
| 4 | import { UPLOAD_MAX_FILES, UPLOAD_MAX_FILE_SIZE } from '@/lib/constants' | ||
| 5 | |||
| 6 | // Client-side file validation function | ||
| 7 | function validateFile(file: File): { allowed: boolean; reason?: string } { | ||
| 8 | if (file.size > UPLOAD_MAX_FILE_SIZE) { | ||
| 9 | return { allowed: false, reason: `File size exceeds ${UPLOAD_MAX_FILE_SIZE / (1024 * 1024)}MB limit` }; | ||
| 10 | } | ||
| 11 | |||
| 12 | return { allowed: true }; | ||
| 13 | } | ||
| 14 | |||
| 15 | interface FileUploadProps { | ||
| 16 | targetPath: string | ||
| 17 | onUploadComplete?: () => void | ||
| 18 | } | ||
| 19 | |||
| 20 | interface UploadResult { | ||
| 21 | filename: string | ||
| 22 | success: boolean | ||
| 23 | message: string | ||
| 24 | } | ||
| 25 | |||
| 26 | export default function FileUpload({ targetPath, onUploadComplete }: FileUploadProps) { | ||
| 27 | const [isDragOver, setIsDragOver] = useState(false) | ||
| 28 | const [isUploading, setIsUploading] = useState(false) | ||
| 29 | const [selectedFiles, setSelectedFiles] = useState<File[]>([]) | ||
| 30 | const [uploadResults, setUploadResults] = useState<UploadResult[]>([]) | ||
| 31 | const [showResults, setShowResults] = useState(false) | ||
| 32 | const fileInputRef = useRef<HTMLInputElement>(null) | ||
| 33 | |||
| 34 | const handleFileSelect = (files: FileList) => { | ||
| 35 | const fileArray = Array.from(files) | ||
| 36 | |||
| 37 | // Validate file count | ||
| 38 | if (fileArray.length > UPLOAD_MAX_FILES) { | ||
| 39 | alert(`Too many files selected. Maximum ${UPLOAD_MAX_FILES} files allowed.`) | ||
| 40 | return | ||
| 41 | } | ||
| 42 | |||
| 43 | // Validate each file | ||
| 44 | const validFiles: File[] = [] | ||
| 45 | for (const file of fileArray) { | ||
| 46 | const validation = validateFile(file) | ||
| 47 | if (!validation.allowed) { | ||
| 48 | alert(`File '${file.name}': ${validation.reason}`) | ||
| 49 | continue | ||
| 50 | } | ||
| 51 | validFiles.push(file) | ||
| 52 | } | ||
| 53 | |||
| 54 | setSelectedFiles(validFiles) | ||
| 55 | setUploadResults([]) | ||
| 56 | setShowResults(false) | ||
| 57 | } | ||
| 58 | |||
| 59 | const handleDragOver = (e: React.DragEvent) => { | ||
| 60 | e.preventDefault() | ||
| 61 | setIsDragOver(true) | ||
| 62 | } | ||
| 63 | |||
| 64 | const handleDragLeave = (e: React.DragEvent) => { | ||
| 65 | e.preventDefault() | ||
| 66 | setIsDragOver(false) | ||
| 67 | } | ||
| 68 | |||
| 69 | const handleDrop = (e: React.DragEvent) => { | ||
| 70 | e.preventDefault() | ||
| 71 | setIsDragOver(false) | ||
| 72 | |||
| 73 | if (e.dataTransfer.files) { | ||
| 74 | handleFileSelect(e.dataTransfer.files) | ||
| 75 | } | ||
| 76 | } | ||
| 77 | |||
| 78 | const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { | ||
| 79 | if (e.target.files) { | ||
| 80 | handleFileSelect(e.target.files) | ||
| 81 | } | ||
| 82 | } | ||
| 83 | |||
| 84 | const handleUpload = async () => { | ||
| 85 | if (selectedFiles.length === 0) return | ||
| 86 | |||
| 87 | setIsUploading(true) | ||
| 88 | setUploadResults([]) | ||
| 89 | |||
| 90 | try { | ||
| 91 | const formData = new FormData() | ||
| 92 | selectedFiles.forEach(file => { | ||
| 93 | formData.append('files', file) | ||
| 94 | }) | ||
| 95 | formData.append('targetPath', targetPath) | ||
| 96 | |||
| 97 | const response = await fetch('/api/upload', { | ||
| 98 | method: 'POST', | ||
| 99 | body: formData, | ||
| 100 | }) | ||
| 101 | |||
| 102 | const result = await response.json() | ||
| 103 | |||
| 104 | if (response.ok) { | ||
| 105 | setUploadResults(result.results || []) | ||
| 106 | setShowResults(true) | ||
| 107 | setSelectedFiles([]) | ||
| 108 | |||
| 109 | // Clear file input | ||
| 110 | if (fileInputRef.current) { | ||
| 111 | fileInputRef.current.value = '' | ||
| 112 | } | ||
| 113 | |||
| 114 | // Refresh the page after successful upload | ||
| 115 | setTimeout(() => { | ||
| 116 | window.location.reload() | ||
| 117 | }, 1000) | ||
| 118 | } else { | ||
| 119 | alert(`Upload failed: ${result.error}`) | ||
| 120 | } | ||
| 121 | } catch (error) { | ||
| 122 | console.error('Upload error:', error) | ||
| 123 | alert('Upload failed: Network error') | ||
| 124 | } finally { | ||
| 125 | setIsUploading(false) | ||
| 126 | } | ||
| 127 | } | ||
| 128 | |||
| 129 | const removeFile = (index: number) => { | ||
| 130 | setSelectedFiles(prev => prev.filter((_, i) => i !== index)) | ||
| 131 | } | ||
| 132 | |||
| 133 | const clearResults = () => { | ||
| 134 | setShowResults(false) | ||
| 135 | setUploadResults([]) | ||
| 136 | } | ||
| 137 | |||
| 138 | return ( | ||
| 139 | <div className="mb-6 p-4 border border-gray-200 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-800"> | ||
| 140 | <h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-gray-100">Upload Files</h2> | ||
| 141 | |||
| 142 | {/* Upload Results */} | ||
| 143 | {showResults && uploadResults.length > 0 && ( | ||
| 144 | <div className="mb-4 p-3 bg-white dark:bg-gray-900 rounded border"> | ||
| 145 | <div className="flex justify-between items-center mb-2"> | ||
| 146 | <h3 className="font-medium text-gray-900 dark:text-gray-100">Upload Results</h3> | ||
| 147 | <button | ||
| 148 | onClick={clearResults} | ||
| 149 | className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200" | ||
| 150 | > | ||
| 151 | Clear | ||
| 152 | </button> | ||
| 153 | </div> | ||
| 154 | <div className="space-y-1"> | ||
| 155 | {uploadResults.map((result, index) => ( | ||
| 156 | <div | ||
| 157 | key={index} | ||
| 158 | className={`text-sm flex items-center gap-2 ${ | ||
| 159 | result.success | ||
| 160 | ? 'text-green-600 dark:text-green-400' | ||
| 161 | : 'text-red-600 dark:text-red-400' | ||
| 162 | }`} | ||
| 163 | > | ||
| 164 | <span>{result.success ? '✓' : '✗'}</span> | ||
| 165 | <span className="font-medium">{result.filename}:</span> | ||
| 166 | <span>{result.message}</span> | ||
| 167 | </div> | ||
| 168 | ))} | ||
| 169 | </div> | ||
| 170 | </div> | ||
| 171 | )} | ||
| 172 | |||
| 173 | {/* File Drop Zone */} | ||
| 174 | <div | ||
| 175 | className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${ | ||
| 176 | isDragOver | ||
| 177 | ? 'border-blue-400 bg-blue-50 dark:bg-blue-900/20' | ||
| 178 | : 'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500' | ||
| 179 | }`} | ||
| 180 | onDragOver={handleDragOver} | ||
| 181 | onDragLeave={handleDragLeave} | ||
| 182 | onDrop={handleDrop} | ||
| 183 | > | ||
| 184 | <div className="space-y-2"> | ||
| 185 | <div className="text-4xl">📁</div> | ||
| 186 | <div className="text-gray-600 dark:text-gray-300"> | ||
| 187 | <p className="font-medium">Drop files here or click to browse</p> | ||
| 188 | <p className="text-sm"> | ||
| 189 | Maximum {UPLOAD_MAX_FILES} files, {UPLOAD_MAX_FILE_SIZE / (1024 * 1024)}MB each | ||
| 190 | </p> | ||
| 191 | </div> | ||
| 192 | <input | ||
| 193 | ref={fileInputRef} | ||
| 194 | type="file" | ||
| 195 | multiple | ||
| 196 | className="hidden" | ||
| 197 | onChange={handleFileInputChange} | ||
| 198 | /> | ||
| 199 | <button | ||
| 200 | type="button" | ||
| 201 | onClick={() => fileInputRef.current?.click()} | ||
| 202 | disabled={isUploading} | ||
| 203 | className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:bg-gray-400 transition-colors" | ||
| 204 | > | ||
| 205 | Browse Files | ||
| 206 | </button> | ||
| 207 | </div> | ||
| 208 | </div> | ||
| 209 | |||
| 210 | {/* Selected Files */} | ||
| 211 | {selectedFiles.length > 0 && ( | ||
| 212 | <div className="mt-4"> | ||
| 213 | <h3 className="font-medium mb-2 text-gray-900 dark:text-gray-100"> | ||
| 214 | Selected Files ({selectedFiles.length}) | ||
| 215 | </h3> | ||
| 216 | <div className="space-y-2"> | ||
| 217 | {selectedFiles.map((file, index) => ( | ||
| 218 | <div | ||
| 219 | key={index} | ||
| 220 | className="flex items-center justify-between p-2 bg-white dark:bg-gray-900 rounded border" | ||
| 221 | > | ||
| 222 | <div className="flex items-center gap-2"> | ||
| 223 | <span className="text-gray-400">📄</span> | ||
| 224 | <div> | ||
| 225 | <div className="text-sm font-medium text-gray-900 dark:text-gray-100"> | ||
| 226 | {file.name} | ||
| 227 | </div> | ||
| 228 | <div className="text-xs text-gray-500 dark:text-gray-400"> | ||
| 229 | {(file.size / 1024 / 1024).toFixed(2)} MB | ||
| 230 | </div> | ||
| 231 | </div> | ||
| 232 | </div> | ||
| 233 | <button | ||
| 234 | onClick={() => removeFile(index)} | ||
| 235 | disabled={isUploading} | ||
| 236 | className="text-red-500 hover:text-red-700 disabled:text-gray-400 text-sm" | ||
| 237 | > | ||
| 238 | Remove | ||
| 239 | </button> | ||
| 240 | </div> | ||
| 241 | ))} | ||
| 242 | </div> | ||
| 243 | |||
| 244 | <button | ||
| 245 | onClick={handleUpload} | ||
| 246 | disabled={isUploading || selectedFiles.length === 0} | ||
| 247 | className="mt-3 px-6 py-2 bg-green-500 text-white rounded hover:bg-green-600 disabled:bg-gray-400 transition-colors flex items-center gap-2" | ||
| 248 | > | ||
| 249 | {isUploading ? ( | ||
| 250 | <> | ||
| 251 | <div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent"></div> | ||
| 252 | Uploading... | ||
| 253 | </> | ||
| 254 | ) : ( | ||
| 255 | `Upload ${selectedFiles.length} file${selectedFiles.length > 1 ? 's' : ''}` | ||
| 256 | )} | ||
| 257 | </button> | ||
| 258 | </div> | ||
| 259 | )} | ||
| 260 | </div> | ||
| 261 | ) | ||
| 262 | } \ No newline at end of file | ||
diff --git a/frontend/components/ui/badge.tsx b/frontend/components/ui/badge.tsx new file mode 100644 index 0000000..0205413 --- /dev/null +++ b/frontend/components/ui/badge.tsx | |||
| @@ -0,0 +1,46 @@ | |||
| 1 | import * as React from "react" | ||
| 2 | import { Slot } from "@radix-ui/react-slot" | ||
| 3 | import { cva, type VariantProps } from "class-variance-authority" | ||
| 4 | |||
| 5 | import { cn } from "@/lib/utils" | ||
| 6 | |||
| 7 | const badgeVariants = cva( | ||
| 8 | "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", | ||
| 9 | { | ||
| 10 | variants: { | ||
| 11 | variant: { | ||
| 12 | default: | ||
| 13 | "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", | ||
| 14 | secondary: | ||
| 15 | "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", | ||
| 16 | destructive: | ||
| 17 | "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", | ||
| 18 | outline: | ||
| 19 | "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", | ||
| 20 | }, | ||
| 21 | }, | ||
| 22 | defaultVariants: { | ||
| 23 | variant: "default", | ||
| 24 | }, | ||
| 25 | } | ||
| 26 | ) | ||
| 27 | |||
| 28 | function Badge({ | ||
| 29 | className, | ||
| 30 | variant, | ||
| 31 | asChild = false, | ||
| 32 | ...props | ||
| 33 | }: React.ComponentProps<"span"> & | ||
| 34 | VariantProps<typeof badgeVariants> & { asChild?: boolean }) { | ||
| 35 | const Comp = asChild ? Slot : "span" | ||
| 36 | |||
| 37 | return ( | ||
| 38 | <Comp | ||
| 39 | data-slot="badge" | ||
| 40 | className={cn(badgeVariants({ variant }), className)} | ||
| 41 | {...props} | ||
| 42 | /> | ||
| 43 | ) | ||
| 44 | } | ||
| 45 | |||
| 46 | export { Badge, badgeVariants } | ||
diff --git a/frontend/components/ui/button.tsx b/frontend/components/ui/button.tsx new file mode 100644 index 0000000..a2df8dc --- /dev/null +++ b/frontend/components/ui/button.tsx | |||
| @@ -0,0 +1,59 @@ | |||
| 1 | import * as React from "react" | ||
| 2 | import { Slot } from "@radix-ui/react-slot" | ||
| 3 | import { cva, type VariantProps } from "class-variance-authority" | ||
| 4 | |||
| 5 | import { cn } from "@/lib/utils" | ||
| 6 | |||
| 7 | const buttonVariants = cva( | ||
| 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", | ||
| 9 | { | ||
| 10 | variants: { | ||
| 11 | variant: { | ||
| 12 | default: | ||
| 13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", | ||
| 14 | destructive: | ||
| 15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", | ||
| 16 | outline: | ||
| 17 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", | ||
| 18 | secondary: | ||
| 19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", | ||
| 20 | ghost: | ||
| 21 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", | ||
| 22 | link: "text-primary underline-offset-4 hover:underline", | ||
| 23 | }, | ||
| 24 | size: { | ||
| 25 | default: "h-9 px-4 py-2 has-[>svg]:px-3", | ||
| 26 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", | ||
| 27 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4", | ||
| 28 | icon: "size-9", | ||
| 29 | }, | ||
| 30 | }, | ||
| 31 | defaultVariants: { | ||
| 32 | variant: "default", | ||
| 33 | size: "default", | ||
| 34 | }, | ||
| 35 | } | ||
| 36 | ) | ||
| 37 | |||
| 38 | function Button({ | ||
| 39 | className, | ||
| 40 | variant, | ||
| 41 | size, | ||
| 42 | asChild = false, | ||
| 43 | ...props | ||
| 44 | }: React.ComponentProps<"button"> & | ||
| 45 | VariantProps<typeof buttonVariants> & { | ||
| 46 | asChild?: boolean | ||
| 47 | }) { | ||
| 48 | const Comp = asChild ? Slot : "button" | ||
| 49 | |||
| 50 | return ( | ||
| 51 | <Comp | ||
| 52 | data-slot="button" | ||
| 53 | className={cn(buttonVariants({ variant, size, className }))} | ||
| 54 | {...props} | ||
| 55 | /> | ||
| 56 | ) | ||
| 57 | } | ||
| 58 | |||
| 59 | export { Button, buttonVariants } | ||
diff --git a/frontend/components/ui/checkbox.tsx b/frontend/components/ui/checkbox.tsx new file mode 100644 index 0000000..b0f1ccf --- /dev/null +++ b/frontend/components/ui/checkbox.tsx | |||
| @@ -0,0 +1,47 @@ | |||
| 1 | "use client" | ||
| 2 | |||
| 3 | import * as React from "react" | ||
| 4 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox" | ||
| 5 | import { CheckIcon } from "lucide-react" | ||
| 6 | |||
| 7 | import { cn } from "@/lib/utils" | ||
| 8 | |||
| 9 | const Checkbox = React.forwardRef< | ||
| 10 | React.ElementRef<typeof CheckboxPrimitive.Root>, | ||
| 11 | React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> & { | ||
| 12 | indeterminate?: boolean | ||
| 13 | } | ||
| 14 | >(({ className, indeterminate, ...props }, ref) => { | ||
| 15 | const checkboxRef = React.useRef<HTMLButtonElement>(null) | ||
| 16 | |||
| 17 | React.useImperativeHandle(ref, () => checkboxRef.current!) | ||
| 18 | |||
| 19 | React.useEffect(() => { | ||
| 20 | if (checkboxRef.current) { | ||
| 21 | checkboxRef.current.indeterminate = indeterminate ?? false | ||
| 22 | } | ||
| 23 | }, [indeterminate]) | ||
| 24 | |||
| 25 | return ( | ||
| 26 | <CheckboxPrimitive.Root | ||
| 27 | ref={checkboxRef} | ||
| 28 | data-slot="checkbox" | ||
| 29 | className={cn( | ||
| 30 | "peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", | ||
| 31 | className | ||
| 32 | )} | ||
| 33 | {...props} | ||
| 34 | > | ||
| 35 | <CheckboxPrimitive.Indicator | ||
| 36 | data-slot="checkbox-indicator" | ||
| 37 | className="flex items-center justify-center text-current transition-none" | ||
| 38 | > | ||
| 39 | <CheckIcon className="size-3.5" /> | ||
| 40 | </CheckboxPrimitive.Indicator> | ||
| 41 | </CheckboxPrimitive.Root> | ||
| 42 | ) | ||
| 43 | }) | ||
| 44 | |||
| 45 | Checkbox.displayName = "Checkbox" | ||
| 46 | |||
| 47 | export { Checkbox } | ||
diff --git a/frontend/components/ui/dialog.tsx b/frontend/components/ui/dialog.tsx new file mode 100644 index 0000000..d9ccec9 --- /dev/null +++ b/frontend/components/ui/dialog.tsx | |||
| @@ -0,0 +1,143 @@ | |||
| 1 | "use client" | ||
| 2 | |||
| 3 | import * as React from "react" | ||
| 4 | import * as DialogPrimitive from "@radix-ui/react-dialog" | ||
| 5 | import { XIcon } from "lucide-react" | ||
| 6 | |||
| 7 | import { cn } from "@/lib/utils" | ||
| 8 | |||
| 9 | function Dialog({ | ||
| 10 | ...props | ||
| 11 | }: React.ComponentProps<typeof DialogPrimitive.Root>) { | ||
| 12 | return <DialogPrimitive.Root data-slot="dialog" {...props} /> | ||
| 13 | } | ||
| 14 | |||
| 15 | function DialogTrigger({ | ||
| 16 | ...props | ||
| 17 | }: React.ComponentProps<typeof DialogPrimitive.Trigger>) { | ||
| 18 | return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} /> | ||
| 19 | } | ||
| 20 | |||
| 21 | function DialogPortal({ | ||
| 22 | ...props | ||
| 23 | }: React.ComponentProps<typeof DialogPrimitive.Portal>) { | ||
| 24 | return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} /> | ||
| 25 | } | ||
| 26 | |||
| 27 | function DialogClose({ | ||
| 28 | ...props | ||
| 29 | }: React.ComponentProps<typeof DialogPrimitive.Close>) { | ||
| 30 | return <DialogPrimitive.Close data-slot="dialog-close" {...props} /> | ||
| 31 | } | ||
| 32 | |||
| 33 | function DialogOverlay({ | ||
| 34 | className, | ||
| 35 | ...props | ||
| 36 | }: React.ComponentProps<typeof DialogPrimitive.Overlay>) { | ||
| 37 | return ( | ||
| 38 | <DialogPrimitive.Overlay | ||
| 39 | data-slot="dialog-overlay" | ||
| 40 | className={cn( | ||
| 41 | "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", | ||
| 42 | className | ||
| 43 | )} | ||
| 44 | {...props} | ||
| 45 | /> | ||
| 46 | ) | ||
| 47 | } | ||
| 48 | |||
| 49 | function DialogContent({ | ||
| 50 | className, | ||
| 51 | children, | ||
| 52 | showCloseButton = true, | ||
| 53 | ...props | ||
| 54 | }: React.ComponentProps<typeof DialogPrimitive.Content> & { | ||
| 55 | showCloseButton?: boolean | ||
| 56 | }) { | ||
| 57 | return ( | ||
| 58 | <DialogPortal data-slot="dialog-portal"> | ||
| 59 | <DialogOverlay /> | ||
| 60 | <DialogPrimitive.Content | ||
| 61 | data-slot="dialog-content" | ||
| 62 | className={cn( | ||
| 63 | "bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg", | ||
| 64 | className | ||
| 65 | )} | ||
| 66 | {...props} | ||
| 67 | > | ||
| 68 | {children} | ||
| 69 | {showCloseButton && ( | ||
| 70 | <DialogPrimitive.Close | ||
| 71 | data-slot="dialog-close" | ||
| 72 | className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4" | ||
| 73 | > | ||
| 74 | <XIcon /> | ||
| 75 | <span className="sr-only">Close</span> | ||
| 76 | </DialogPrimitive.Close> | ||
| 77 | )} | ||
| 78 | </DialogPrimitive.Content> | ||
| 79 | </DialogPortal> | ||
| 80 | ) | ||
| 81 | } | ||
| 82 | |||
| 83 | function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { | ||
| 84 | return ( | ||
| 85 | <div | ||
| 86 | data-slot="dialog-header" | ||
| 87 | className={cn("flex flex-col gap-2 text-center sm:text-left", className)} | ||
| 88 | {...props} | ||
| 89 | /> | ||
| 90 | ) | ||
| 91 | } | ||
| 92 | |||
| 93 | function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { | ||
| 94 | return ( | ||
| 95 | <div | ||
| 96 | data-slot="dialog-footer" | ||
| 97 | className={cn( | ||
| 98 | "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", | ||
| 99 | className | ||
| 100 | )} | ||
| 101 | {...props} | ||
| 102 | /> | ||
| 103 | ) | ||
| 104 | } | ||
| 105 | |||
| 106 | function DialogTitle({ | ||
| 107 | className, | ||
| 108 | ...props | ||
| 109 | }: React.ComponentProps<typeof DialogPrimitive.Title>) { | ||
| 110 | return ( | ||
| 111 | <DialogPrimitive.Title | ||
| 112 | data-slot="dialog-title" | ||
| 113 | className={cn("text-lg leading-none font-semibold", className)} | ||
| 114 | {...props} | ||
| 115 | /> | ||
| 116 | ) | ||
| 117 | } | ||
| 118 | |||
| 119 | function DialogDescription({ | ||
| 120 | className, | ||
| 121 | ...props | ||
| 122 | }: React.ComponentProps<typeof DialogPrimitive.Description>) { | ||
| 123 | return ( | ||
| 124 | <DialogPrimitive.Description | ||
| 125 | data-slot="dialog-description" | ||
| 126 | className={cn("text-muted-foreground text-sm", className)} | ||
| 127 | {...props} | ||
| 128 | /> | ||
| 129 | ) | ||
| 130 | } | ||
| 131 | |||
| 132 | export { | ||
| 133 | Dialog, | ||
| 134 | DialogClose, | ||
| 135 | DialogContent, | ||
| 136 | DialogDescription, | ||
| 137 | DialogFooter, | ||
| 138 | DialogHeader, | ||
| 139 | DialogOverlay, | ||
| 140 | DialogPortal, | ||
| 141 | DialogTitle, | ||
| 142 | DialogTrigger, | ||
| 143 | } | ||
diff --git a/frontend/components/ui/dropdown-menu.tsx b/frontend/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..ec51e9c --- /dev/null +++ b/frontend/components/ui/dropdown-menu.tsx | |||
| @@ -0,0 +1,257 @@ | |||
| 1 | "use client" | ||
| 2 | |||
| 3 | import * as React from "react" | ||
| 4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" | ||
| 5 | import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" | ||
| 6 | |||
| 7 | import { cn } from "@/lib/utils" | ||
| 8 | |||
| 9 | function DropdownMenu({ | ||
| 10 | ...props | ||
| 11 | }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) { | ||
| 12 | return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} /> | ||
| 13 | } | ||
| 14 | |||
| 15 | function DropdownMenuPortal({ | ||
| 16 | ...props | ||
| 17 | }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) { | ||
| 18 | return ( | ||
| 19 | <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} /> | ||
| 20 | ) | ||
| 21 | } | ||
| 22 | |||
| 23 | function DropdownMenuTrigger({ | ||
| 24 | ...props | ||
| 25 | }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) { | ||
| 26 | return ( | ||
| 27 | <DropdownMenuPrimitive.Trigger | ||
| 28 | data-slot="dropdown-menu-trigger" | ||
| 29 | {...props} | ||
| 30 | /> | ||
| 31 | ) | ||
| 32 | } | ||
| 33 | |||
| 34 | function DropdownMenuContent({ | ||
| 35 | className, | ||
| 36 | sideOffset = 4, | ||
| 37 | ...props | ||
| 38 | }: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) { | ||
| 39 | return ( | ||
| 40 | <DropdownMenuPrimitive.Portal> | ||
| 41 | <DropdownMenuPrimitive.Content | ||
| 42 | data-slot="dropdown-menu-content" | ||
| 43 | sideOffset={sideOffset} | ||
| 44 | className={cn( | ||
| 45 | "bg-popover text-popover-foreground 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=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md", | ||
| 46 | className | ||
| 47 | )} | ||
| 48 | {...props} | ||
| 49 | /> | ||
| 50 | </DropdownMenuPrimitive.Portal> | ||
| 51 | ) | ||
| 52 | } | ||
| 53 | |||
| 54 | function DropdownMenuGroup({ | ||
| 55 | ...props | ||
| 56 | }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) { | ||
| 57 | return ( | ||
| 58 | <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} /> | ||
| 59 | ) | ||
| 60 | } | ||
| 61 | |||
| 62 | function DropdownMenuItem({ | ||
| 63 | className, | ||
| 64 | inset, | ||
| 65 | variant = "default", | ||
| 66 | ...props | ||
| 67 | }: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & { | ||
| 68 | inset?: boolean | ||
| 69 | variant?: "default" | "destructive" | ||
| 70 | }) { | ||
| 71 | return ( | ||
| 72 | <DropdownMenuPrimitive.Item | ||
| 73 | data-slot="dropdown-menu-item" | ||
| 74 | data-inset={inset} | ||
| 75 | data-variant={variant} | ||
| 76 | className={cn( | ||
| 77 | "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", | ||
| 78 | className | ||
| 79 | )} | ||
| 80 | {...props} | ||
| 81 | /> | ||
| 82 | ) | ||
| 83 | } | ||
| 84 | |||
| 85 | function DropdownMenuCheckboxItem({ | ||
| 86 | className, | ||
| 87 | children, | ||
| 88 | checked, | ||
| 89 | ...props | ||
| 90 | }: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) { | ||
| 91 | return ( | ||
| 92 | <DropdownMenuPrimitive.CheckboxItem | ||
| 93 | data-slot="dropdown-menu-checkbox-item" | ||
| 94 | className={cn( | ||
| 95 | "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", | ||
| 96 | className | ||
| 97 | )} | ||
| 98 | checked={checked} | ||
| 99 | {...props} | ||
| 100 | > | ||
| 101 | <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"> | ||
| 102 | <DropdownMenuPrimitive.ItemIndicator> | ||
| 103 | <CheckIcon className="size-4" /> | ||
| 104 | </DropdownMenuPrimitive.ItemIndicator> | ||
| 105 | </span> | ||
| 106 | {children} | ||
| 107 | </DropdownMenuPrimitive.CheckboxItem> | ||
| 108 | ) | ||
| 109 | } | ||
| 110 | |||
| 111 | function DropdownMenuRadioGroup({ | ||
| 112 | ...props | ||
| 113 | }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) { | ||
| 114 | return ( | ||
| 115 | <DropdownMenuPrimitive.RadioGroup | ||
| 116 | data-slot="dropdown-menu-radio-group" | ||
| 117 | {...props} | ||
| 118 | /> | ||
| 119 | ) | ||
| 120 | } | ||
| 121 | |||
| 122 | function DropdownMenuRadioItem({ | ||
| 123 | className, | ||
| 124 | children, | ||
| 125 | ...props | ||
| 126 | }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) { | ||
| 127 | return ( | ||
| 128 | <DropdownMenuPrimitive.RadioItem | ||
| 129 | data-slot="dropdown-menu-radio-item" | ||
| 130 | className={cn( | ||
| 131 | "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", | ||
| 132 | className | ||
| 133 | )} | ||
| 134 | {...props} | ||
| 135 | > | ||
| 136 | <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"> | ||
| 137 | <DropdownMenuPrimitive.ItemIndicator> | ||
| 138 | <CircleIcon className="size-2 fill-current" /> | ||
| 139 | </DropdownMenuPrimitive.ItemIndicator> | ||
| 140 | </span> | ||
| 141 | {children} | ||
| 142 | </DropdownMenuPrimitive.RadioItem> | ||
| 143 | ) | ||
| 144 | } | ||
| 145 | |||
| 146 | function DropdownMenuLabel({ | ||
| 147 | className, | ||
| 148 | inset, | ||
| 149 | ...props | ||
| 150 | }: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & { | ||
| 151 | inset?: boolean | ||
| 152 | }) { | ||
| 153 | return ( | ||
| 154 | <DropdownMenuPrimitive.Label | ||
| 155 | data-slot="dropdown-menu-label" | ||
| 156 | data-inset={inset} | ||
| 157 | className={cn( | ||
| 158 | "px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", | ||
| 159 | className | ||
| 160 | )} | ||
| 161 | {...props} | ||
| 162 | /> | ||
| 163 | ) | ||
| 164 | } | ||
| 165 | |||
| 166 | function DropdownMenuSeparator({ | ||
| 167 | className, | ||
| 168 | ...props | ||
| 169 | }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) { | ||
| 170 | return ( | ||
| 171 | <DropdownMenuPrimitive.Separator | ||
| 172 | data-slot="dropdown-menu-separator" | ||
| 173 | className={cn("bg-border -mx-1 my-1 h-px", className)} | ||
| 174 | {...props} | ||
| 175 | /> | ||
| 176 | ) | ||
| 177 | } | ||
| 178 | |||
| 179 | function DropdownMenuShortcut({ | ||
| 180 | className, | ||
| 181 | ...props | ||
| 182 | }: React.ComponentProps<"span">) { | ||
| 183 | return ( | ||
| 184 | <span | ||
| 185 | data-slot="dropdown-menu-shortcut" | ||
| 186 | className={cn( | ||
| 187 | "text-muted-foreground ml-auto text-xs tracking-widest", | ||
| 188 | className | ||
| 189 | )} | ||
| 190 | {...props} | ||
| 191 | /> | ||
| 192 | ) | ||
| 193 | } | ||
| 194 | |||
| 195 | function DropdownMenuSub({ | ||
| 196 | ...props | ||
| 197 | }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) { | ||
| 198 | return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} /> | ||
| 199 | } | ||
| 200 | |||
| 201 | function DropdownMenuSubTrigger({ | ||
| 202 | className, | ||
| 203 | inset, | ||
| 204 | children, | ||
| 205 | ...props | ||
| 206 | }: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & { | ||
| 207 | inset?: boolean | ||
| 208 | }) { | ||
| 209 | return ( | ||
| 210 | <DropdownMenuPrimitive.SubTrigger | ||
| 211 | data-slot="dropdown-menu-sub-trigger" | ||
| 212 | data-inset={inset} | ||
| 213 | className={cn( | ||
| 214 | "focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8", | ||
| 215 | className | ||
| 216 | )} | ||
| 217 | {...props} | ||
| 218 | > | ||
| 219 | {children} | ||
| 220 | <ChevronRightIcon className="ml-auto size-4" /> | ||
| 221 | </DropdownMenuPrimitive.SubTrigger> | ||
| 222 | ) | ||
| 223 | } | ||
| 224 | |||
| 225 | function DropdownMenuSubContent({ | ||
| 226 | className, | ||
| 227 | ...props | ||
| 228 | }: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) { | ||
| 229 | return ( | ||
| 230 | <DropdownMenuPrimitive.SubContent | ||
| 231 | data-slot="dropdown-menu-sub-content" | ||
| 232 | className={cn( | ||
| 233 | "bg-popover text-popover-foreground 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=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg", | ||
| 234 | className | ||
| 235 | )} | ||
| 236 | {...props} | ||
| 237 | /> | ||
| 238 | ) | ||
| 239 | } | ||
| 240 | |||
| 241 | export { | ||
| 242 | DropdownMenu, | ||
| 243 | DropdownMenuPortal, | ||
| 244 | DropdownMenuTrigger, | ||
| 245 | DropdownMenuContent, | ||
| 246 | DropdownMenuGroup, | ||
| 247 | DropdownMenuLabel, | ||
| 248 | DropdownMenuItem, | ||
| 249 | DropdownMenuCheckboxItem, | ||
| 250 | DropdownMenuRadioGroup, | ||
| 251 | DropdownMenuRadioItem, | ||
| 252 | DropdownMenuSeparator, | ||
| 253 | DropdownMenuShortcut, | ||
| 254 | DropdownMenuSub, | ||
| 255 | DropdownMenuSubTrigger, | ||
| 256 | DropdownMenuSubContent, | ||
| 257 | } | ||
diff --git a/frontend/components/ui/input.tsx b/frontend/components/ui/input.tsx new file mode 100644 index 0000000..03295ca --- /dev/null +++ b/frontend/components/ui/input.tsx | |||
| @@ -0,0 +1,21 @@ | |||
| 1 | import * as React from "react" | ||
| 2 | |||
| 3 | import { cn } from "@/lib/utils" | ||
| 4 | |||
| 5 | function Input({ className, type, ...props }: React.ComponentProps<"input">) { | ||
| 6 | return ( | ||
| 7 | <input | ||
| 8 | type={type} | ||
| 9 | data-slot="input" | ||
| 10 | className={cn( | ||
| 11 | "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", | ||
| 12 | "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", | ||
| 13 | "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", | ||
| 14 | className | ||
| 15 | )} | ||
| 16 | {...props} | ||
| 17 | /> | ||
| 18 | ) | ||
| 19 | } | ||
| 20 | |||
| 21 | export { Input } | ||
diff --git a/frontend/components/ui/label.tsx b/frontend/components/ui/label.tsx new file mode 100644 index 0000000..fb5fbc3 --- /dev/null +++ b/frontend/components/ui/label.tsx | |||
| @@ -0,0 +1,24 @@ | |||
| 1 | "use client" | ||
| 2 | |||
| 3 | import * as React from "react" | ||
| 4 | import * as LabelPrimitive from "@radix-ui/react-label" | ||
| 5 | |||
| 6 | import { cn } from "@/lib/utils" | ||
| 7 | |||
| 8 | function Label({ | ||
| 9 | className, | ||
| 10 | ...props | ||
| 11 | }: React.ComponentProps<typeof LabelPrimitive.Root>) { | ||
| 12 | return ( | ||
| 13 | <LabelPrimitive.Root | ||
| 14 | data-slot="label" | ||
| 15 | className={cn( | ||
| 16 | "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", | ||
| 17 | className | ||
| 18 | )} | ||
| 19 | {...props} | ||
| 20 | /> | ||
| 21 | ) | ||
| 22 | } | ||
| 23 | |||
| 24 | export { Label } | ||
diff --git a/frontend/components/ui/progress.tsx b/frontend/components/ui/progress.tsx new file mode 100644 index 0000000..e7a416c --- /dev/null +++ b/frontend/components/ui/progress.tsx | |||
| @@ -0,0 +1,31 @@ | |||
| 1 | "use client" | ||
| 2 | |||
| 3 | import * as React from "react" | ||
| 4 | import * as ProgressPrimitive from "@radix-ui/react-progress" | ||
| 5 | |||
| 6 | import { cn } from "@/lib/utils" | ||
| 7 | |||
| 8 | function Progress({ | ||
| 9 | className, | ||
| 10 | value, | ||
| 11 | ...props | ||
| 12 | }: React.ComponentProps<typeof ProgressPrimitive.Root>) { | ||
| 13 | return ( | ||
| 14 | <ProgressPrimitive.Root | ||
| 15 | data-slot="progress" | ||
| 16 | className={cn( | ||
| 17 | "bg-primary/20 relative h-2 w-full overflow-hidden rounded-full", | ||
| 18 | className | ||
| 19 | )} | ||
| 20 | {...props} | ||
| 21 | > | ||
| 22 | <ProgressPrimitive.Indicator | ||
| 23 | data-slot="progress-indicator" | ||
| 24 | className="bg-primary h-full w-full flex-1 transition-all" | ||
| 25 | style={{ transform: `translateX(-${100 - (value || 0)}%)` }} | ||
| 26 | /> | ||
| 27 | </ProgressPrimitive.Root> | ||
| 28 | ) | ||
| 29 | } | ||
| 30 | |||
| 31 | export { Progress } | ||
diff --git a/frontend/components/ui/table.tsx b/frontend/components/ui/table.tsx new file mode 100644 index 0000000..51b74dd --- /dev/null +++ b/frontend/components/ui/table.tsx | |||
| @@ -0,0 +1,116 @@ | |||
| 1 | "use client" | ||
| 2 | |||
| 3 | import * as React from "react" | ||
| 4 | |||
| 5 | import { cn } from "@/lib/utils" | ||
| 6 | |||
| 7 | function Table({ className, ...props }: React.ComponentProps<"table">) { | ||
| 8 | return ( | ||
| 9 | <div | ||
| 10 | data-slot="table-container" | ||
| 11 | className="relative w-full overflow-x-auto" | ||
| 12 | > | ||
| 13 | <table | ||
| 14 | data-slot="table" | ||
| 15 | className={cn("w-full caption-bottom text-sm", className)} | ||
| 16 | {...props} | ||
| 17 | /> | ||
| 18 | </div> | ||
| 19 | ) | ||
| 20 | } | ||
| 21 | |||
| 22 | function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { | ||
| 23 | return ( | ||
| 24 | <thead | ||
| 25 | data-slot="table-header" | ||
| 26 | className={cn("[&_tr]:border-b", className)} | ||
| 27 | {...props} | ||
| 28 | /> | ||
| 29 | ) | ||
| 30 | } | ||
| 31 | |||
| 32 | function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { | ||
| 33 | return ( | ||
| 34 | <tbody | ||
| 35 | data-slot="table-body" | ||
| 36 | className={cn("[&_tr:last-child]:border-0", className)} | ||
| 37 | {...props} | ||
| 38 | /> | ||
| 39 | ) | ||
| 40 | } | ||
| 41 | |||
| 42 | function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { | ||
| 43 | return ( | ||
| 44 | <tfoot | ||
| 45 | data-slot="table-footer" | ||
| 46 | className={cn( | ||
| 47 | "bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", | ||
| 48 | className | ||
| 49 | )} | ||
| 50 | {...props} | ||
| 51 | /> | ||
| 52 | ) | ||
| 53 | } | ||
| 54 | |||
| 55 | function TableRow({ className, ...props }: React.ComponentProps<"tr">) { | ||
| 56 | return ( | ||
| 57 | <tr | ||
| 58 | data-slot="table-row" | ||
| 59 | className={cn( | ||
| 60 | "hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", | ||
| 61 | className | ||
| 62 | )} | ||
| 63 | {...props} | ||
| 64 | /> | ||
| 65 | ) | ||
| 66 | } | ||
| 67 | |||
| 68 | function TableHead({ className, ...props }: React.ComponentProps<"th">) { | ||
| 69 | return ( | ||
| 70 | <th | ||
| 71 | data-slot="table-head" | ||
| 72 | className={cn( | ||
| 73 | "text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", | ||
| 74 | className | ||
| 75 | )} | ||
| 76 | {...props} | ||
| 77 | /> | ||
| 78 | ) | ||
| 79 | } | ||
| 80 | |||
| 81 | function TableCell({ className, ...props }: React.ComponentProps<"td">) { | ||
| 82 | return ( | ||
| 83 | <td | ||
| 84 | data-slot="table-cell" | ||
| 85 | className={cn( | ||
| 86 | "p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", | ||
| 87 | className | ||
| 88 | )} | ||
| 89 | {...props} | ||
| 90 | /> | ||
| 91 | ) | ||
| 92 | } | ||
| 93 | |||
| 94 | function TableCaption({ | ||
| 95 | className, | ||
| 96 | ...props | ||
| 97 | }: React.ComponentProps<"caption">) { | ||
| 98 | return ( | ||
| 99 | <caption | ||
| 100 | data-slot="table-caption" | ||
| 101 | className={cn("text-muted-foreground mt-4 text-sm", className)} | ||
| 102 | {...props} | ||
| 103 | /> | ||
| 104 | ) | ||
| 105 | } | ||
| 106 | |||
| 107 | export { | ||
| 108 | Table, | ||
| 109 | TableHeader, | ||
| 110 | TableBody, | ||
| 111 | TableFooter, | ||
| 112 | TableHead, | ||
| 113 | TableRow, | ||
| 114 | TableCell, | ||
| 115 | TableCaption, | ||
| 116 | } | ||
diff --git a/frontend/components/ui/toast.tsx b/frontend/components/ui/toast.tsx new file mode 100644 index 0000000..6d2e12f --- /dev/null +++ b/frontend/components/ui/toast.tsx | |||
| @@ -0,0 +1,129 @@ | |||
| 1 | "use client" | ||
| 2 | |||
| 3 | import * as React from "react" | ||
| 4 | import * as ToastPrimitives from "@radix-ui/react-toast" | ||
| 5 | import { cva, type VariantProps } from "class-variance-authority" | ||
| 6 | import { X } from "lucide-react" | ||
| 7 | |||
| 8 | import { cn } from "@/lib/utils" | ||
| 9 | |||
| 10 | const ToastProvider = ToastPrimitives.Provider | ||
| 11 | |||
| 12 | const ToastViewport = React.forwardRef< | ||
| 13 | React.ElementRef<typeof ToastPrimitives.Viewport>, | ||
| 14 | React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport> | ||
| 15 | >(({ className, ...props }, ref) => ( | ||
| 16 | <ToastPrimitives.Viewport | ||
| 17 | ref={ref} | ||
| 18 | className={cn( | ||
| 19 | "fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]", | ||
| 20 | className | ||
| 21 | )} | ||
| 22 | {...props} | ||
| 23 | /> | ||
| 24 | )) | ||
| 25 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName | ||
| 26 | |||
| 27 | const toastVariants = cva( | ||
| 28 | "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", | ||
| 29 | { | ||
| 30 | variants: { | ||
| 31 | variant: { | ||
| 32 | default: "border bg-background text-foreground", | ||
| 33 | destructive: | ||
| 34 | "destructive border-destructive bg-destructive text-destructive-foreground", | ||
| 35 | }, | ||
| 36 | }, | ||
| 37 | defaultVariants: { | ||
| 38 | variant: "default", | ||
| 39 | }, | ||
| 40 | } | ||
| 41 | ) | ||
| 42 | |||
| 43 | const Toast = React.forwardRef< | ||
| 44 | React.ElementRef<typeof ToastPrimitives.Root>, | ||
| 45 | React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & | ||
| 46 | VariantProps<typeof toastVariants> | ||
| 47 | >(({ className, variant, ...props }, ref) => { | ||
| 48 | return ( | ||
| 49 | <ToastPrimitives.Root | ||
| 50 | ref={ref} | ||
| 51 | className={cn(toastVariants({ variant }), className)} | ||
| 52 | {...props} | ||
| 53 | /> | ||
| 54 | ) | ||
| 55 | }) | ||
| 56 | Toast.displayName = ToastPrimitives.Root.displayName | ||
| 57 | |||
| 58 | const ToastAction = React.forwardRef< | ||
| 59 | React.ElementRef<typeof ToastPrimitives.Action>, | ||
| 60 | React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action> | ||
| 61 | >(({ className, ...props }, ref) => ( | ||
| 62 | <ToastPrimitives.Action | ||
| 63 | ref={ref} | ||
| 64 | className={cn( | ||
| 65 | "inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive", | ||
| 66 | className | ||
| 67 | )} | ||
| 68 | {...props} | ||
| 69 | /> | ||
| 70 | )) | ||
| 71 | ToastAction.displayName = ToastPrimitives.Action.displayName | ||
| 72 | |||
| 73 | const ToastClose = React.forwardRef< | ||
| 74 | React.ElementRef<typeof ToastPrimitives.Close>, | ||
| 75 | React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close> | ||
| 76 | >(({ className, ...props }, ref) => ( | ||
| 77 | <ToastPrimitives.Close | ||
| 78 | ref={ref} | ||
| 79 | className={cn( | ||
| 80 | "absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600", | ||
| 81 | className | ||
| 82 | )} | ||
| 83 | toast-close="" | ||
| 84 | {...props} | ||
| 85 | > | ||
| 86 | <X className="h-4 w-4" /> | ||
| 87 | </ToastPrimitives.Close> | ||
| 88 | )) | ||
| 89 | ToastClose.displayName = ToastPrimitives.Close.displayName | ||
| 90 | |||
| 91 | const ToastTitle = React.forwardRef< | ||
| 92 | React.ElementRef<typeof ToastPrimitives.Title>, | ||
| 93 | React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title> | ||
| 94 | >(({ className, ...props }, ref) => ( | ||
| 95 | <ToastPrimitives.Title | ||
| 96 | ref={ref} | ||
| 97 | className={cn("text-sm font-semibold", className)} | ||
| 98 | {...props} | ||
| 99 | /> | ||
| 100 | )) | ||
| 101 | ToastTitle.displayName = ToastPrimitives.Title.displayName | ||
| 102 | |||
| 103 | const ToastDescription = React.forwardRef< | ||
| 104 | React.ElementRef<typeof ToastPrimitives.Description>, | ||
| 105 | React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description> | ||
| 106 | >(({ className, ...props }, ref) => ( | ||
| 107 | <ToastPrimitives.Description | ||
| 108 | ref={ref} | ||
| 109 | className={cn("text-sm opacity-90", className)} | ||
| 110 | {...props} | ||
| 111 | /> | ||
| 112 | )) | ||
| 113 | ToastDescription.displayName = ToastPrimitives.Description.displayName | ||
| 114 | |||
| 115 | type ToastProps = React.ComponentPropsWithoutRef<typeof Toast> | ||
| 116 | |||
| 117 | type ToastActionElement = React.ReactElement<typeof ToastAction> | ||
| 118 | |||
| 119 | export { | ||
| 120 | type ToastProps, | ||
| 121 | type ToastActionElement, | ||
| 122 | ToastProvider, | ||
| 123 | ToastViewport, | ||
| 124 | Toast, | ||
| 125 | ToastTitle, | ||
| 126 | ToastDescription, | ||
| 127 | ToastClose, | ||
| 128 | ToastAction, | ||
| 129 | } \ No newline at end of file | ||
diff --git a/frontend/components/ui/toaster.tsx b/frontend/components/ui/toaster.tsx new file mode 100644 index 0000000..b5b97f6 --- /dev/null +++ b/frontend/components/ui/toaster.tsx | |||
| @@ -0,0 +1,35 @@ | |||
| 1 | "use client" | ||
| 2 | |||
| 3 | import { | ||
| 4 | Toast, | ||
| 5 | ToastClose, | ||
| 6 | ToastDescription, | ||
| 7 | ToastProvider, | ||
| 8 | ToastTitle, | ||
| 9 | ToastViewport, | ||
| 10 | } from "@/components/ui/toast" | ||
| 11 | import { useToast } from "@/hooks/use-toast" | ||
| 12 | |||
| 13 | export function Toaster() { | ||
| 14 | const { toasts } = useToast() | ||
| 15 | |||
| 16 | return ( | ||
| 17 | <ToastProvider> | ||
| 18 | {toasts.map(function ({ id, title, description, action, ...props }) { | ||
| 19 | return ( | ||
| 20 | <Toast key={id} {...props}> | ||
| 21 | <div className="grid gap-1"> | ||
| 22 | {title && <ToastTitle>{title}</ToastTitle>} | ||
| 23 | {description && ( | ||
| 24 | <ToastDescription>{description}</ToastDescription> | ||
| 25 | )} | ||
| 26 | </div> | ||
| 27 | {action} | ||
| 28 | <ToastClose /> | ||
| 29 | </Toast> | ||
| 30 | ) | ||
| 31 | })} | ||
| 32 | <ToastViewport /> | ||
| 33 | </ToastProvider> | ||
| 34 | ) | ||
| 35 | } \ No newline at end of file | ||
