summaryrefslogtreecommitdiff
path: root/frontend/components
diff options
context:
space:
mode:
authordiogo464 <[email protected]>2025-08-11 16:04:32 +0100
committerdiogo464 <[email protected]>2025-08-11 16:04:32 +0100
commitf4d8a26972728891de8bde4eeb94c80f027ce2d2 (patch)
tree3c8b9c25c2a1e3fab7a86f51922c39eb2ed93697 /frontend/components
parent32b008a9c0c8e0130ab10bc96ffea9232f9cf95a (diff)
basic v0 ui working
Diffstat (limited to 'frontend/components')
-rw-r--r--frontend/components/AuthButton.tsx31
-rw-r--r--frontend/components/FileTable.tsx179
-rw-r--r--frontend/components/FileUpload.tsx262
-rw-r--r--frontend/components/ui/badge.tsx46
-rw-r--r--frontend/components/ui/button.tsx59
-rw-r--r--frontend/components/ui/checkbox.tsx47
-rw-r--r--frontend/components/ui/dialog.tsx143
-rw-r--r--frontend/components/ui/dropdown-menu.tsx257
-rw-r--r--frontend/components/ui/input.tsx21
-rw-r--r--frontend/components/ui/label.tsx24
-rw-r--r--frontend/components/ui/progress.tsx31
-rw-r--r--frontend/components/ui/table.tsx116
-rw-r--r--frontend/components/ui/toast.tsx129
-rw-r--r--frontend/components/ui/toaster.tsx35
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 @@
1import { Auth_get_user, Auth_tinyauth_public_endpoint } from '@/lib/auth'
2
3export 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
3import { useState } from 'react'
4import { DriveLsEntry } from '@/lib/drive_types'
5import { Drive_basename } from '@/lib/drive_shared'
6import { formatSize } from '@/lib/utils'
7import Link from 'next/link'
8
9interface FileTableEntry extends DriveLsEntry {
10 isParent?: boolean
11 parentPath?: string
12}
13
14interface FileTableProps {
15 entries: DriveLsEntry[]
16 currentPath?: string
17 showParent?: boolean
18 parentPath?: string
19 onSelectedFilesChange?: (selectedFiles: string[]) => void
20}
21
22export 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
3import { useState, useRef } from 'react'
4import { UPLOAD_MAX_FILES, UPLOAD_MAX_FILE_SIZE } from '@/lib/constants'
5
6// Client-side file validation function
7function 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
15interface FileUploadProps {
16 targetPath: string
17 onUploadComplete?: () => void
18}
19
20interface UploadResult {
21 filename: string
22 success: boolean
23 message: string
24}
25
26export 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 @@
1import * as React from "react"
2import { Slot } from "@radix-ui/react-slot"
3import { cva, type VariantProps } from "class-variance-authority"
4
5import { cn } from "@/lib/utils"
6
7const 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
28function 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
46export { 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 @@
1import * as React from "react"
2import { Slot } from "@radix-ui/react-slot"
3import { cva, type VariantProps } from "class-variance-authority"
4
5import { cn } from "@/lib/utils"
6
7const 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
38function 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
59export { 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
3import * as React from "react"
4import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
5import { CheckIcon } from "lucide-react"
6
7import { cn } from "@/lib/utils"
8
9const 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
45Checkbox.displayName = "Checkbox"
46
47export { 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
3import * as React from "react"
4import * as DialogPrimitive from "@radix-ui/react-dialog"
5import { XIcon } from "lucide-react"
6
7import { cn } from "@/lib/utils"
8
9function Dialog({
10 ...props
11}: React.ComponentProps<typeof DialogPrimitive.Root>) {
12 return <DialogPrimitive.Root data-slot="dialog" {...props} />
13}
14
15function DialogTrigger({
16 ...props
17}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
18 return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
19}
20
21function DialogPortal({
22 ...props
23}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
24 return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
25}
26
27function DialogClose({
28 ...props
29}: React.ComponentProps<typeof DialogPrimitive.Close>) {
30 return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
31}
32
33function 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
49function 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
83function 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
93function 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
106function 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
119function 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
132export {
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
3import * as React from "react"
4import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
5import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
6
7import { cn } from "@/lib/utils"
8
9function DropdownMenu({
10 ...props
11}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
12 return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
13}
14
15function DropdownMenuPortal({
16 ...props
17}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
18 return (
19 <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
20 )
21}
22
23function 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
34function 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
54function DropdownMenuGroup({
55 ...props
56}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
57 return (
58 <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
59 )
60}
61
62function 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
85function 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
111function 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
122function 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
146function 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
166function 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
179function 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
195function DropdownMenuSub({
196 ...props
197}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
198 return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
199}
200
201function 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
225function 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
241export {
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 @@
1import * as React from "react"
2
3import { cn } from "@/lib/utils"
4
5function 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
21export { 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
3import * as React from "react"
4import * as LabelPrimitive from "@radix-ui/react-label"
5
6import { cn } from "@/lib/utils"
7
8function 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
24export { 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
3import * as React from "react"
4import * as ProgressPrimitive from "@radix-ui/react-progress"
5
6import { cn } from "@/lib/utils"
7
8function 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
31export { 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
3import * as React from "react"
4
5import { cn } from "@/lib/utils"
6
7function 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
22function 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
32function 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
42function 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
55function 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
68function 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
81function 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
94function 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
107export {
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
3import * as React from "react"
4import * as ToastPrimitives from "@radix-ui/react-toast"
5import { cva, type VariantProps } from "class-variance-authority"
6import { X } from "lucide-react"
7
8import { cn } from "@/lib/utils"
9
10const ToastProvider = ToastPrimitives.Provider
11
12const 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))
25ToastViewport.displayName = ToastPrimitives.Viewport.displayName
26
27const 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
43const 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})
56Toast.displayName = ToastPrimitives.Root.displayName
57
58const 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))
71ToastAction.displayName = ToastPrimitives.Action.displayName
72
73const 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))
89ToastClose.displayName = ToastPrimitives.Close.displayName
90
91const 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))
101ToastTitle.displayName = ToastPrimitives.Title.displayName
102
103const 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))
113ToastDescription.displayName = ToastPrimitives.Description.displayName
114
115type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
116
117type ToastActionElement = React.ReactElement<typeof ToastAction>
118
119export {
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
3import {
4 Toast,
5 ToastClose,
6 ToastDescription,
7 ToastProvider,
8 ToastTitle,
9 ToastViewport,
10} from "@/components/ui/toast"
11import { useToast } from "@/hooks/use-toast"
12
13export 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