newly added multi doc upload service
This commit is contained in:
parent
de9a6e9eb7
commit
8a7f6f74dc
@ -28,6 +28,8 @@ import ViewUserReposButton from "@/components/github/ViewUserReposButton"
|
||||
import { ErrorBanner } from "@/components/ui/error-banner"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
import { authApiClient } from "@/components/apis/authApiClients"
|
||||
import { MultiDocsUploadInline } from "@/components/multi-docs-upload-inline"
|
||||
import { MultiDocsUploadStep } from "@/components/multi-docs-upload-step"
|
||||
|
||||
interface Template {
|
||||
id: string
|
||||
@ -1143,6 +1145,11 @@ function TemplateSelectionStep({ onNext }: { onNext: (template: Template) => voi
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Multi Docs Upload Inline Section */}
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<MultiDocsUploadInline />
|
||||
</div>
|
||||
|
||||
{templates.length === 0 && !paginationState.loading ? (
|
||||
<div className="text-center py-12 text-white/60">
|
||||
<p>No templates found for the current filters.</p>
|
||||
@ -2356,6 +2363,7 @@ export function MainDashboard() {
|
||||
// Only access localStorage after component mounts to prevent hydration mismatch
|
||||
return null
|
||||
})
|
||||
const [multiDocJobId, setMultiDocJobId] = useState<string | null>(null)
|
||||
|
||||
// Load state from localStorage after component mounts
|
||||
useEffect(() => {
|
||||
@ -2413,7 +2421,6 @@ export function MainDashboard() {
|
||||
const steps = [
|
||||
{ id: 1, name: "Project Type", description: "Choose template" },
|
||||
{ id: 2, name: "Features", description: "Select features" },
|
||||
// { id: 3, name: "AI Mockup", description: "Generate wireframes" },
|
||||
{ id: 3, name: "Business Context", description: "Define requirements" },
|
||||
{ id: 4, name: "Generate", description: "Create project" },
|
||||
{ id: 5, name: "Architecture", description: "Review & deploy" },
|
||||
|
||||
339
src/components/multi-docs-upload-inline.tsx
Normal file
339
src/components/multi-docs-upload-inline.tsx
Normal file
@ -0,0 +1,339 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||
import { Upload, File as FileIcon, Loader2, ChevronDown, ChevronUp, X } from "lucide-react"
|
||||
import { getApiUrl } from "@/config/backend"
|
||||
|
||||
interface JobStatus {
|
||||
job_id: string
|
||||
stage: string
|
||||
status_message?: string | null
|
||||
processed_files: number
|
||||
total_files: number
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
const STAGE_COPY: Record<string, string> = {
|
||||
received: "Job received",
|
||||
saving_files: "Saving files",
|
||||
extracting: "Extracting document content",
|
||||
analyzing: "Calling Claude for causal relations",
|
||||
building_graph: "Writing to Neo4j knowledge graph",
|
||||
completed: "Completed",
|
||||
failed: "Failed",
|
||||
}
|
||||
|
||||
interface MultiDocsUploadInlineProps {
|
||||
onJobCreated?: (jobId: string) => void
|
||||
}
|
||||
|
||||
// Default export for easier importing
|
||||
export default MultiDocsUploadInline
|
||||
|
||||
export function MultiDocsUploadInline({ onJobCreated }: MultiDocsUploadInlineProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [jobId, setJobId] = useState<string | null>(null)
|
||||
const [status, setStatus] = useState<JobStatus | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const uploadUrl = useMemo(() => getApiUrl("/api/multi-docs/jobs"), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!jobId) return
|
||||
|
||||
let cancelled = false
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const response = await fetch(getApiUrl(`/api/multi-docs/jobs/${jobId}`))
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status request failed with ${response.status}`)
|
||||
}
|
||||
const data = (await response.json()) as JobStatus
|
||||
if (!cancelled) {
|
||||
setStatus(data)
|
||||
if (data.stage === "completed" || data.stage === "failed") {
|
||||
clearInterval(interval)
|
||||
if (data.stage === "completed" && onJobCreated) {
|
||||
onJobCreated(jobId)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (!cancelled) {
|
||||
console.error("Failed to poll job status:", err)
|
||||
setError(err.message ?? "Failed to fetch job status")
|
||||
clearInterval(interval)
|
||||
}
|
||||
}
|
||||
}, 4000)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, [jobId, onJobCreated])
|
||||
|
||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!event.target.files) return
|
||||
const selected = Array.from(event.target.files)
|
||||
setFiles(prev => [...prev, ...selected])
|
||||
}
|
||||
|
||||
const removeFile = (fileName: string) => {
|
||||
setFiles(prev => prev.filter(file => file.name !== fileName))
|
||||
}
|
||||
|
||||
const startUpload = async () => {
|
||||
setError(null)
|
||||
if (files.length === 0) {
|
||||
setError("Please select at least one file.")
|
||||
return
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
files.forEach(file => formData.append("files", file))
|
||||
|
||||
setUploading(true)
|
||||
try {
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = `Upload failed with ${response.status}`
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
errorMessage = errorData.detail || errorData.message || errorMessage
|
||||
} catch {
|
||||
const text = await response.text()
|
||||
errorMessage = text || errorMessage
|
||||
}
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setJobId(data.job_id)
|
||||
setStatus({
|
||||
job_id: data.job_id,
|
||||
stage: data.stage,
|
||||
total_files: data.total_files,
|
||||
processed_files: 0,
|
||||
})
|
||||
setUploading(false)
|
||||
setIsExpanded(true) // Keep expanded when upload starts
|
||||
} catch (err: any) {
|
||||
console.error("Upload failed:", err)
|
||||
setError(err.message ?? "Upload failed. Please try again.")
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate progress based on stage and file processing
|
||||
const progressPercent = status
|
||||
? (() => {
|
||||
// File extraction progress (0-30%)
|
||||
const fileProgress = status.total_files > 0
|
||||
? Math.round((status.processed_files / status.total_files) * 30)
|
||||
: 0
|
||||
|
||||
// Stage-based progress
|
||||
let stageProgress = 0
|
||||
if (status.stage === "saving_files") {
|
||||
stageProgress = 5
|
||||
} else if (status.stage === "extracting") {
|
||||
stageProgress = 10 + fileProgress // 10-40%
|
||||
} else if (status.stage === "analyzing") {
|
||||
// Analyzing takes the longest, show 40-85%
|
||||
stageProgress = 40 + Math.min(fileProgress * 1.5, 45) // 40-85%
|
||||
} else if (status.stage === "building_graph") {
|
||||
stageProgress = 85 + Math.min(fileProgress * 0.5, 10) // 85-95%
|
||||
} else if (status.stage === "completed") {
|
||||
stageProgress = 100
|
||||
} else if (status.stage === "failed") {
|
||||
stageProgress = fileProgress // Show progress up to failure point
|
||||
}
|
||||
|
||||
return Math.min(stageProgress, 100)
|
||||
})()
|
||||
: 0
|
||||
|
||||
const resetUpload = () => {
|
||||
setFiles([])
|
||||
setJobId(null)
|
||||
setStatus(null)
|
||||
setError(null)
|
||||
setUploading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-white/5 border border-white/10 rounded-xl">
|
||||
<CardHeader
|
||||
className="cursor-pointer hover:bg-white/5 transition-colors"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Upload className="h-5 w-5 text-orange-400" />
|
||||
<CardTitle className="text-lg text-white">
|
||||
Upload Documents for Knowledge Graph
|
||||
</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{status && status.stage === "completed" && (
|
||||
<span className="text-xs text-green-400 bg-green-400/10 px-2 py-1 rounded-full">
|
||||
Completed
|
||||
</span>
|
||||
)}
|
||||
{status && status.stage === "failed" && (
|
||||
<span className="text-xs text-red-400 bg-red-400/10 px-2 py-1 rounded-full">
|
||||
Failed
|
||||
</span>
|
||||
)}
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-5 w-5 text-white/60" />
|
||||
) : (
|
||||
<ChevronDown className="h-5 w-5 text-white/60" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-white/60 mt-2">
|
||||
Upload documents to extract causal relationships and build a knowledge graph in Neo4j
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
{isExpanded && (
|
||||
<CardContent className="space-y-4">
|
||||
{status?.stage === "completed" ? (
|
||||
<div className="space-y-3">
|
||||
<Alert className="bg-green-500/10 border border-green-500/40 text-green-200">
|
||||
<AlertDescription>
|
||||
✅ Knowledge graph created successfully! {status.total_files} file(s) processed.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button
|
||||
onClick={resetUpload}
|
||||
variant="outline"
|
||||
className="w-full border-white/20 text-white hover:bg-white/10"
|
||||
>
|
||||
Upload More Documents
|
||||
</Button>
|
||||
</div>
|
||||
) : status?.stage === "failed" ? (
|
||||
<div className="space-y-3">
|
||||
<Alert variant="destructive" className="bg-red-500/10 border border-red-500/40 text-red-200">
|
||||
<AlertDescription>
|
||||
{error || "Upload failed. Please try again."}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button
|
||||
onClick={resetUpload}
|
||||
variant="outline"
|
||||
className="w-full border-white/20 text-white hover:bg-white/10"
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="border border-dashed border-orange-400/40 rounded-lg p-4 text-center bg-black/40">
|
||||
<input
|
||||
id="multi-docs-input-inline"
|
||||
type="file"
|
||||
multiple
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
disabled={uploading}
|
||||
/>
|
||||
<label
|
||||
htmlFor="multi-docs-input-inline"
|
||||
className={`flex flex-col items-center cursor-pointer space-y-2 ${uploading ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<Upload className="h-8 w-8 text-orange-400" />
|
||||
<div>
|
||||
<p className="font-semibold text-white text-sm">Click to select files</p>
|
||||
<p className="text-xs text-white/60">
|
||||
PDF, DOCX, PPT, XLS, JSON, XML, Markdown, images, audio/video
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="bg-black/40 border border-white/10 rounded-lg p-3 space-y-2">
|
||||
<p className="text-xs font-semibold text-white/70">Selected Files ({files.length})</p>
|
||||
<ul className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{files.map(file => (
|
||||
<li key={file.name} className="flex items-center justify-between text-xs text-white/80 bg-white/5 rounded px-2 py-1">
|
||||
<span className="flex items-center gap-2">
|
||||
<FileIcon className="h-3 w-3 text-orange-400" />
|
||||
<span className="truncate max-w-[200px]">{file.name}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-orange-300 hover:text-orange-200"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
removeFile(file.name)
|
||||
}}
|
||||
disabled={uploading}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive" className="bg-red-500/10 border border-red-500/40 text-red-200">
|
||||
<AlertDescription className="text-xs">{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{status && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs text-white/70">
|
||||
<span>{STAGE_COPY[status.stage] || status.stage}</span>
|
||||
<span>{progressPercent}%</span>
|
||||
</div>
|
||||
<Progress value={progressPercent} className="h-1.5" />
|
||||
{status.status_message && (
|
||||
<p className="text-xs text-white/60">{status.status_message}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
startUpload()
|
||||
}}
|
||||
className="w-full bg-orange-500 hover:bg-orange-600 text-black font-semibold"
|
||||
disabled={uploading || files.length === 0}
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Uploading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Start Upload <Upload className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
258
src/components/multi-docs-upload-step.tsx
Normal file
258
src/components/multi-docs-upload-step.tsx
Normal file
@ -0,0 +1,258 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { Upload, File as FileIcon, Loader2, ArrowLeft, ArrowRight } from "lucide-react"
|
||||
import { getApiUrl } from "@/config/backend"
|
||||
|
||||
interface MultiDocsUploadStepProps {
|
||||
onBack: () => void
|
||||
onNext: (jobId: string | null) => void
|
||||
}
|
||||
|
||||
interface JobStatus {
|
||||
job_id: string
|
||||
stage: string
|
||||
status_message?: string | null
|
||||
processed_files: number
|
||||
total_files: number
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
const STAGE_COPY: Record<string, string> = {
|
||||
received: "Job received",
|
||||
saving_files: "Saving files",
|
||||
extracting: "Extracting document content",
|
||||
analyzing: "Calling Claude for causal relations",
|
||||
building_graph: "Writing to Neo4j knowledge graph",
|
||||
completed: "Completed",
|
||||
failed: "Failed",
|
||||
}
|
||||
|
||||
export function MultiDocsUploadStep({ onBack, onNext }: MultiDocsUploadStepProps) {
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [jobId, setJobId] = useState<string | null>(null)
|
||||
const [status, setStatus] = useState<JobStatus | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const uploadUrl = useMemo(() => getApiUrl("/api/multi-docs/jobs"), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!jobId) return
|
||||
|
||||
let cancelled = false
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const response = await fetch(getApiUrl(`/api/multi-docs/jobs/${jobId}`))
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status request failed with ${response.status}`)
|
||||
}
|
||||
const data = (await response.json()) as JobStatus
|
||||
if (!cancelled) {
|
||||
setStatus(data)
|
||||
if (data.stage === "completed" || data.stage === "failed") {
|
||||
clearInterval(interval)
|
||||
if (data.stage === "completed") {
|
||||
onNext(jobId)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (!cancelled) {
|
||||
console.error("Failed to poll job status:", err)
|
||||
setError(err.message ?? "Failed to fetch job status")
|
||||
clearInterval(interval)
|
||||
}
|
||||
}
|
||||
}, 4000)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, [jobId, onNext])
|
||||
|
||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!event.target.files) return
|
||||
const selected = Array.from(event.target.files)
|
||||
setFiles(prev => [...prev, ...selected])
|
||||
}
|
||||
|
||||
const removeFile = (fileName: string) => {
|
||||
setFiles(prev => prev.filter(file => file.name !== fileName))
|
||||
}
|
||||
|
||||
const startUpload = async () => {
|
||||
setError(null)
|
||||
if (files.length === 0) {
|
||||
setError("Please select at least one file.")
|
||||
return
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
files.forEach(file => formData.append("files", file))
|
||||
|
||||
setUploading(true)
|
||||
try {
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = `Upload failed with ${response.status}`
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
errorMessage = errorData.detail || errorData.message || errorMessage
|
||||
} catch {
|
||||
const text = await response.text()
|
||||
errorMessage = text || errorMessage
|
||||
}
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setJobId(data.job_id)
|
||||
setStatus({
|
||||
job_id: data.job_id,
|
||||
stage: data.stage,
|
||||
total_files: data.total_files,
|
||||
processed_files: 0,
|
||||
})
|
||||
setUploading(false)
|
||||
} catch (err: any) {
|
||||
console.error("Upload failed:", err)
|
||||
setError(err.message ?? "Upload failed. Please try again.")
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const progressPercent = status
|
||||
? status.total_files > 0
|
||||
? Math.round((status.processed_files / status.total_files) * 100)
|
||||
: 0
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div className="min-h-[70vh] bg-gradient-to-b from-black to-black/80 text-white">
|
||||
<div className="max-w-5xl mx-auto py-12 px-6">
|
||||
<Card className="bg-white/5 border border-white/10">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl text-orange-400">Multi Docs Upload</CardTitle>
|
||||
<p className="text-sm text-white/60">
|
||||
Upload multiple documents at once. We'll extract granular context, call Claude Sonnet, and store the causal knowledge graph in Neo4j.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="border border-dashed border-orange-400/40 rounded-lg p-6 text-center bg-black/40">
|
||||
<input
|
||||
id="multi-docs-input"
|
||||
type="file"
|
||||
multiple
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
<label htmlFor="multi-docs-input" className="flex flex-col items-center cursor-pointer space-y-3">
|
||||
<Upload className="h-10 w-10 text-orange-400" />
|
||||
<div>
|
||||
<p className="font-semibold text-white">Click to select files</p>
|
||||
<p className="text-sm text-white/60">
|
||||
Supports PDF, DOCX, PPT, XLS, JSON, XML, Markdown, images, audio/video and more. You can mix formats.
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs uppercase tracking-wide text-orange-300 bg-orange-400/10 px-3 py-1 rounded-full">
|
||||
Claude model: Sonnet 20241022 (High-accuracy causal extraction)
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="bg-black/40 border border-white/10 rounded-lg p-4 space-y-3">
|
||||
<p className="text-sm font-semibold text-white/70">Selected Files ({files.length})</p>
|
||||
<ul className="space-y-2 max-h-48 overflow-y-auto">
|
||||
{files.map(file => (
|
||||
<li key={file.name} className="flex items-center justify-between text-sm text-white/80 bg-white/5 rounded-md px-3 py-2">
|
||||
<span className="flex items-center gap-2">
|
||||
<FileIcon className="h-4 w-4 text-orange-400" />
|
||||
{file.name}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-orange-300 hover:text-orange-200 text-xs"
|
||||
onClick={() => removeFile(file.name)}
|
||||
disabled={uploading}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive" className="bg-red-500/10 border border-red-500/40 text-red-200">
|
||||
<AlertTitle>Upload issue</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{status && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm text-white/70">
|
||||
<span>{STAGE_COPY[status.stage] || status.stage}</span>
|
||||
<span>{progressPercent}%</span>
|
||||
</div>
|
||||
<Progress value={progressPercent} className="h-2" />
|
||||
{status.status_message && (
|
||||
<p className="text-xs text-white/60">{status.status_message}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between pt-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onBack}
|
||||
className="bg-transparent border border-white/20 text-white hover:bg-white/10"
|
||||
disabled={uploading}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" /> Back
|
||||
</Button>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onNext(jobId)}
|
||||
className="bg-transparent border border-white/20 text-white hover:bg-white/10"
|
||||
disabled={!jobId || uploading}
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
<Button
|
||||
onClick={startUpload}
|
||||
className="bg-orange-500 hover:bg-orange-600 text-black font-semibold"
|
||||
disabled={uploading}
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Uploading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Start Upload <ArrowRight className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user