first
This commit is contained in:
120
frontend/src/components/FileUpload.tsx
Normal file
120
frontend/src/components/FileUpload.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Card } from '@/components/ui/card'
|
||||
|
||||
interface FileUploadProps {
|
||||
onFilesSelected: (files: File[]) => void
|
||||
accept?: Record<string, string[]>
|
||||
maxSize?: number
|
||||
maxFiles?: number
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export default function FileUpload({
|
||||
onFilesSelected,
|
||||
accept = {
|
||||
'image/*': ['.png', '.jpg', '.jpeg'],
|
||||
'application/pdf': ['.pdf'],
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
|
||||
'application/msword': ['.doc'],
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['.pptx'],
|
||||
'application/vnd.ms-powerpoint': ['.ppt'],
|
||||
},
|
||||
maxSize = 50 * 1024 * 1024, // 50MB
|
||||
maxFiles = 100,
|
||||
disabled = false,
|
||||
}: FileUploadProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
if (acceptedFiles.length > 0) {
|
||||
onFilesSelected(acceptedFiles)
|
||||
}
|
||||
},
|
||||
[onFilesSelected]
|
||||
)
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive, isDragReject, fileRejections } = useDropzone({
|
||||
onDrop,
|
||||
accept,
|
||||
maxSize,
|
||||
maxFiles,
|
||||
disabled,
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card
|
||||
{...getRootProps()}
|
||||
className={cn(
|
||||
'border-2 border-dashed transition-colors cursor-pointer hover:border-primary/50',
|
||||
{
|
||||
'border-primary bg-primary/5': isDragActive && !isDragReject,
|
||||
'border-destructive bg-destructive/5': isDragReject,
|
||||
'opacity-50 cursor-not-allowed': disabled,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="p-12 text-center">
|
||||
<input {...getInputProps()} />
|
||||
|
||||
<div className="mb-4">
|
||||
<svg
|
||||
className="mx-auto h-12 w-12 text-muted-foreground"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
viewBox="0 0 48 48"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{isDragActive ? (
|
||||
<p className="text-lg font-medium text-primary">
|
||||
{isDragReject ? t('upload.invalidFiles') : t('upload.dropFilesHere')}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-lg font-medium text-foreground">
|
||||
{t('upload.dragAndDrop')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t('upload.supportedFormats')}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('upload.maxFileSize')}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{fileRejections.length > 0 && (
|
||||
<div className="mt-4 p-4 bg-destructive/10 border border-destructive rounded-md">
|
||||
<p className="text-sm font-medium text-destructive mb-2">
|
||||
{t('errors.uploadFailed')}
|
||||
</p>
|
||||
<ul className="text-sm text-destructive space-y-1">
|
||||
{fileRejections.map(({ file, errors }) => (
|
||||
<li key={file.name}>
|
||||
{file.name}:{' '}
|
||||
{errors.map((e) => {
|
||||
if (e.code === 'file-too-large') return t('errors.fileTooBig')
|
||||
if (e.code === 'file-invalid-type') return t('errors.unsupportedFormat')
|
||||
return e.message
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
71
frontend/src/components/Layout.tsx
Normal file
71
frontend/src/components/Layout.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Outlet, NavLink } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { apiClient } from '@/services/api'
|
||||
|
||||
export default function Layout() {
|
||||
const { t } = useTranslation()
|
||||
const logout = useAuthStore((state) => state.logout)
|
||||
|
||||
const handleLogout = () => {
|
||||
apiClient.logout()
|
||||
logout()
|
||||
}
|
||||
|
||||
const navLinks = [
|
||||
{ to: '/upload', label: t('nav.upload') },
|
||||
{ to: '/processing', label: t('nav.processing') },
|
||||
{ to: '/results', label: t('nav.results') },
|
||||
{ to: '/export', label: t('nav.export') },
|
||||
{ to: '/settings', label: t('nav.settings') },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Header */}
|
||||
<header className="border-b bg-card">
|
||||
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">{t('app.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t('app.subtitle')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="px-4 py-2 text-sm font-medium text-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
{t('nav.logout')}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="border-b bg-card">
|
||||
<div className="container mx-auto px-4">
|
||||
<ul className="flex space-x-1">
|
||||
{navLinks.map((link) => (
|
||||
<li key={link.to}>
|
||||
<NavLink
|
||||
to={link.to}
|
||||
className={({ isActive }) =>
|
||||
`block px-4 py-3 text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'text-primary border-b-2 border-primary'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{link.label}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
26
frontend/src/components/MarkdownPreview.tsx
Normal file
26
frontend/src/components/MarkdownPreview.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
||||
interface MarkdownPreviewProps {
|
||||
title?: string
|
||||
content: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function MarkdownPreview({ title, content, className }: MarkdownPreviewProps) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
{title && (
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardContent>
|
||||
<div className="prose prose-sm max-w-none dark:prose-invert">
|
||||
<pre className="whitespace-pre-wrap break-words bg-muted p-4 rounded-md overflow-auto max-h-[600px]">
|
||||
{content}
|
||||
</pre>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
90
frontend/src/components/ResultsTable.tsx
Normal file
90
frontend/src/components/ResultsTable.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { FileResult } from '@/types/api'
|
||||
|
||||
interface ResultsTableProps {
|
||||
files: FileResult[]
|
||||
onViewResult?: (fileId: number) => void
|
||||
onDownloadPDF?: (fileId: number) => void
|
||||
}
|
||||
|
||||
export default function ResultsTable({ files, onViewResult, onDownloadPDF }: ResultsTableProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const getStatusBadge = (status: FileResult['status']) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <Badge variant="success">{t('processing.completed')}</Badge>
|
||||
case 'processing':
|
||||
return <Badge variant="default">{t('processing.processing')}</Badge>
|
||||
case 'failed':
|
||||
return <Badge variant="destructive">{t('processing.failed')}</Badge>
|
||||
default:
|
||||
return <Badge variant="secondary">{t('processing.pending')}</Badge>
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (seconds?: number) => {
|
||||
if (!seconds) return 'N/A'
|
||||
return `${seconds.toFixed(2)}s`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('results.filename')}</TableHead>
|
||||
<TableHead>{t('results.status')}</TableHead>
|
||||
<TableHead>{t('results.processingTime')}</TableHead>
|
||||
<TableHead className="text-right">{t('results.actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{files.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center text-muted-foreground">
|
||||
{t('results.noResults')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
files.map((file) => (
|
||||
<TableRow key={file.id}>
|
||||
<TableCell className="font-medium">{file.filename}</TableCell>
|
||||
<TableCell>{getStatusBadge(file.status)}</TableCell>
|
||||
<TableCell>{formatTime(file.processing_time)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
{file.status === 'completed' && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onViewResult?.(file.id)}
|
||||
>
|
||||
{t('results.viewMarkdown')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onDownloadPDF?.(file.id)}
|
||||
>
|
||||
{t('results.downloadPDF')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{file.status === 'failed' && file.error && (
|
||||
<span className="text-sm text-destructive">{file.error}</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
30
frontend/src/components/ui/badge.tsx
Normal file
30
frontend/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: 'default' | 'secondary' | 'destructive' | 'outline' | 'success'
|
||||
}
|
||||
|
||||
function Badge({ className, variant = 'default', ...props }: BadgeProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
{
|
||||
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80':
|
||||
variant === 'default',
|
||||
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80':
|
||||
variant === 'secondary',
|
||||
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80':
|
||||
variant === 'destructive',
|
||||
'border-transparent bg-green-500 text-white hover:bg-green-600': variant === 'success',
|
||||
'text-foreground': variant === 'outline',
|
||||
},
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge }
|
||||
42
frontend/src/components/ui/button.tsx
Normal file
42
frontend/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'
|
||||
size?: 'default' | 'sm' | 'lg' | 'icon'
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant = 'default', size = 'default', ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background',
|
||||
{
|
||||
'bg-primary text-primary-foreground hover:bg-primary/90': variant === 'default',
|
||||
'bg-destructive text-destructive-foreground hover:bg-destructive/90':
|
||||
variant === 'destructive',
|
||||
'border border-input hover:bg-accent hover:text-accent-foreground':
|
||||
variant === 'outline',
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80':
|
||||
variant === 'secondary',
|
||||
'hover:bg-accent hover:text-accent-foreground': variant === 'ghost',
|
||||
'underline-offset-4 hover:underline text-primary': variant === 'link',
|
||||
},
|
||||
{
|
||||
'h-10 py-2 px-4': size === 'default',
|
||||
'h-9 px-3 rounded-md': size === 'sm',
|
||||
'h-11 px-8 rounded-md': size === 'lg',
|
||||
'h-10 w-10': size === 'icon',
|
||||
},
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export { Button }
|
||||
55
frontend/src/components/ui/card.tsx
Normal file
55
frontend/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Card.displayName = 'Card'
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardHeader.displayName = 'CardHeader'
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn('text-2xl font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
CardTitle.displayName = 'CardTitle'
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
))
|
||||
CardDescription.displayName = 'CardDescription'
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardContent.displayName = 'CardContent'
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardFooter.displayName = 'CardFooter'
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
29
frontend/src/components/ui/progress.tsx
Normal file
29
frontend/src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
value?: number
|
||||
max?: number
|
||||
}
|
||||
|
||||
const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
|
||||
({ className, value = 0, max = 100, ...props }, ref) => {
|
||||
const percentage = Math.min(Math.max((value / max) * 100, 0), 100)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('relative h-4 w-full overflow-hidden rounded-full bg-secondary', className)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className="h-full w-full flex-1 bg-primary transition-all duration-300 ease-in-out"
|
||||
style={{ transform: `translateX(-${100 - percentage}%)` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Progress.displayName = 'Progress'
|
||||
|
||||
export { Progress }
|
||||
70
frontend/src/components/ui/table.tsx
Normal file
70
frontend/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="w-full overflow-auto">
|
||||
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
|
||||
</div>
|
||||
)
|
||||
)
|
||||
Table.displayName = 'Table'
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = 'TableHeader'
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
|
||||
))
|
||||
TableBody.displayName = 'TableBody'
|
||||
|
||||
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
TableRow.displayName = 'TableRow'
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = 'TableHead'
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = 'TableCell'
|
||||
|
||||
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell }
|
||||
116
frontend/src/components/ui/toast.tsx
Normal file
116
frontend/src/components/ui/toast.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type ToastProps = {
|
||||
id: string
|
||||
title?: string
|
||||
description?: string
|
||||
variant?: 'default' | 'destructive' | 'success'
|
||||
duration?: number
|
||||
}
|
||||
|
||||
type ToastContextType = {
|
||||
toasts: ToastProps[]
|
||||
toast: (props: Omit<ToastProps, 'id'>) => void
|
||||
dismiss: (id: string) => void
|
||||
}
|
||||
|
||||
const ToastContext = React.createContext<ToastContextType | undefined>(undefined)
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [toasts, setToasts] = React.useState<ToastProps[]>([])
|
||||
|
||||
const toast = React.useCallback((props: Omit<ToastProps, 'id'>) => {
|
||||
const id = Math.random().toString(36).substr(2, 9)
|
||||
const duration = props.duration ?? 3000
|
||||
|
||||
setToasts((prev) => [...prev, { ...props, id }])
|
||||
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
}, duration)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const dismiss = React.useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toasts, toast, dismiss }}>
|
||||
{children}
|
||||
<ToastViewport toasts={toasts} dismiss={dismiss} />
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const context = React.useContext(ToastContext)
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within ToastProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
function ToastViewport({
|
||||
toasts,
|
||||
dismiss,
|
||||
}: {
|
||||
toasts: ToastProps[]
|
||||
dismiss: (id: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed top-0 right-0 z-50 w-full max-w-md p-4 space-y-4 pointer-events-none">
|
||||
{toasts.map((toast) => (
|
||||
<Toast key={toast.id} {...toast} onDismiss={() => dismiss(toast.id)} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Toast({
|
||||
title,
|
||||
description,
|
||||
variant = 'default',
|
||||
onDismiss,
|
||||
}: ToastProps & { onDismiss: () => void }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-auto w-full rounded-lg border p-4 shadow-lg transition-all',
|
||||
'bg-background text-foreground',
|
||||
{
|
||||
'border-destructive': variant === 'destructive',
|
||||
'border-green-500': variant === 'success',
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-1 space-y-1">
|
||||
{title && <div className="text-sm font-semibold">{title}</div>}
|
||||
{description && <div className="text-sm text-muted-foreground">{description}</div>}
|
||||
</div>
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="text-foreground/50 hover:text-foreground transition-colors"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user