Merge pull request #37 from Yiling-J/extractor-dialog-footer-17605281711190367568
Refactor ExtractorDialog footer layout
This commit is contained in:
8 files changed
+204
-110
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import { Loader2, Play, RefreshCw, Sparkles } from 'lucide-react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { client } from '../client'
|
||||
@@ -6,7 +7,8 @@ import { AnalyzeStage } from './extractor/AnalyzeStage'
|
||||
import { RefineStage } from './extractor/RefineStage'
|
||||
import { SelectionStage } from './extractor/SelectionStage'
|
||||
import { UploadStage } from './extractor/UploadStage'
|
||||
import { Dialog, DialogContent, DialogTitle } from './ui/dialog'
|
||||
import { Button } from './ui/button'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogTitle } from './ui/dialog'
|
||||
|
||||
interface ExtractorDialogProps {
|
||||
open: boolean
|
||||
@@ -54,8 +56,6 @@ export const ExtractorDialog: React.FC<ExtractorDialogProps> = ({
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
// Small delay to allow animation to finish if needed, or just reset immediately
|
||||
// resetting immediately might flash content if closing animation plays
|
||||
// But for now, we reset on close to ensure fresh state next open
|
||||
const t = setTimeout(resetState, 300)
|
||||
return () => clearTimeout(t)
|
||||
}
|
||||
@@ -105,7 +105,9 @@ export const ExtractorDialog: React.FC<ExtractorDialogProps> = ({
|
||||
if (event === 'status') {
|
||||
setStatusMessage(data.message)
|
||||
} else if (event === 'complete') {
|
||||
setCandidates(data.assets as CandidateAsset[])
|
||||
const newCandidates = data.assets as CandidateAsset[]
|
||||
setCandidates(newCandidates)
|
||||
setSelectedIndices(newCandidates.map((_, i) => i))
|
||||
setStage('selection')
|
||||
} else if (event === 'error') {
|
||||
throw new Error(data.message)
|
||||
@@ -122,10 +124,16 @@ export const ExtractorDialog: React.FC<ExtractorDialogProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefineConfirm = (indices: number[]) => {
|
||||
setSelectedIndices(indices)
|
||||
const toggleSelection = (index: number) => {
|
||||
setSelectedIndices((prev) =>
|
||||
prev.includes(index) ? prev.filter((i) => i !== index) : [...prev, index],
|
||||
)
|
||||
}
|
||||
|
||||
const handleRefineConfirm = () => {
|
||||
if (selectedIndices.length === 0) return
|
||||
setStage('refine')
|
||||
handleRefine(indices)
|
||||
handleRefine(selectedIndices)
|
||||
}
|
||||
|
||||
const handleRefine = async (indices: number[]) => {
|
||||
@@ -170,7 +178,6 @@ export const ExtractorDialog: React.FC<ExtractorDialogProps> = ({
|
||||
setResults((prev) => [...prev, data.asset])
|
||||
} else if (event === 'complete') {
|
||||
setIsRefineComplete(true)
|
||||
onSuccess(data.assets)
|
||||
} else if (event === 'error') {
|
||||
throw new Error(data.message)
|
||||
}
|
||||
@@ -182,18 +189,24 @@ export const ExtractorDialog: React.FC<ExtractorDialogProps> = ({
|
||||
console.error(e)
|
||||
const err = e instanceof Error ? e : new Error(String(e))
|
||||
toast.error(err.message || 'Refinement failed')
|
||||
// Don't change stage automatically on error here, let user see what happened or retry?
|
||||
// For now, staying on refine stage allows them to see partial results.
|
||||
// But we might want a 'retry' button or back.
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => onOpenChange(false)
|
||||
|
||||
const handleDone = () => {
|
||||
onSuccess(results)
|
||||
handleClose()
|
||||
}
|
||||
|
||||
const isAnalyzing = stage === 'analyze' && !!statusMessage
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[98vw] w-full max-h-fit p-0 overflow-hidden bg-slate-50 border-none shadow-2xl rounded-2xl flex flex-col">
|
||||
<DialogTitle className="sr-only">Extractor</DialogTitle>
|
||||
|
||||
{/* Top Navigation / Branding Bar (Optional, matches App feel) */}
|
||||
{/* Top Navigation / Branding Bar */}
|
||||
<div className="flex items-center justify-between px-6 py-4 bg-white border-b border-slate-200 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="font-bold text-lg tracking-tight text-slate-800">Extractor</h1>
|
||||
@@ -201,23 +214,18 @@ export const ExtractorDialog: React.FC<ExtractorDialogProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 overflow-hidden relative">
|
||||
<div className="flex-1 overflow-hidden relative min-h-[50vh]">
|
||||
{stage === 'upload' && <UploadStage onImageUpload={handleImageUpload} />}
|
||||
|
||||
{stage === 'analyze' && preview && (
|
||||
<AnalyzeStage
|
||||
imageSrc={preview}
|
||||
statusMessage={statusMessage}
|
||||
isAnalyzing={!!statusMessage}
|
||||
onStart={startAnalysis}
|
||||
/>
|
||||
<AnalyzeStage imageSrc={preview} isAnalyzing={isAnalyzing} />
|
||||
)}
|
||||
|
||||
{stage === 'selection' && (
|
||||
<SelectionStage
|
||||
candidates={candidates}
|
||||
onConfirm={handleRefineConfirm}
|
||||
onCancel={() => setStage('upload')}
|
||||
selectedIndices={selectedIndices}
|
||||
onToggleSelection={toggleSelection}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -226,10 +234,74 @@ export const ExtractorDialog: React.FC<ExtractorDialogProps> = ({
|
||||
selectedCandidates={candidates.filter((_, i) => selectedIndices.includes(i))}
|
||||
results={results}
|
||||
isComplete={isRefineComplete}
|
||||
onDone={resetState}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<DialogFooter className="px-6 py-4 border-t border-slate-200 bg-white shrink-0">
|
||||
{stage === 'upload' && (
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{stage === 'analyze' && (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={isAnalyzing} onClick={startAnalysis} className="min-w-[160px]">
|
||||
{isAnalyzing ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{statusMessage || 'Analyzing...'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="mr-2 h-4 w-4" /> Start Extraction
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{stage === 'selection' && (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setStage('upload')}>
|
||||
Back to Upload
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleRefineConfirm}
|
||||
disabled={selectedIndices.length === 0}
|
||||
className="bg-gradient-to-r from-cyan-500 to-cyan-600 text-white shadow-lg border-none hover:opacity-90 min-w-[160px]"
|
||||
>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
Start Refining ({selectedIndices.length})
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{stage === 'refine' && (
|
||||
<>
|
||||
{isRefineComplete ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={resetState}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" /> Start Over
|
||||
</Button>
|
||||
<Button onClick={handleDone} className="bg-green-600 hover:bg-green-700">
|
||||
Done
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button disabled className="min-w-[160px]">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Refining...
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
import { BrainCircuit, Play } from 'lucide-react'
|
||||
import React from 'react'
|
||||
import { Button } from '../ui/button'
|
||||
|
||||
interface AnalyzeStageProps {
|
||||
imageSrc: string
|
||||
statusMessage: string
|
||||
isAnalyzing: boolean
|
||||
onStart: () => void
|
||||
}
|
||||
|
||||
export const AnalyzeStage: React.FC<AnalyzeStageProps> = ({
|
||||
imageSrc,
|
||||
statusMessage,
|
||||
isAnalyzing,
|
||||
onStart,
|
||||
}) => {
|
||||
export const AnalyzeStage: React.FC<AnalyzeStageProps> = ({ imageSrc, isAnalyzing }) => {
|
||||
return (
|
||||
<div className="relative flex flex-col items-center justify-center w-full h-full overflow-hidden bg-slate-50">
|
||||
{/* Background Frosted Glass Layer */}
|
||||
@@ -49,30 +40,6 @@ export const AnalyzeStage: React.FC<AnalyzeStageProps> = ({
|
||||
<div className="absolute bottom-4 left-4 w-8 h-8 border-b-2 border-l-2 border-cyan-500 rounded-bl-lg z-20"></div>
|
||||
<div className="absolute bottom-4 right-4 w-8 h-8 border-b-2 border-r-2 border-cyan-500 rounded-br-lg z-20"></div>
|
||||
</div>
|
||||
|
||||
{/* Status Text or Action Button */}
|
||||
<div className="mt-8 flex items-center justify-center">
|
||||
{isAnalyzing ? (
|
||||
<div className="flex items-center space-x-3 px-6 py-3 rounded-full bg-white/80 backdrop-blur-md border border-slate-200 shadow-xl">
|
||||
<BrainCircuit className="w-5 h-5 text-cyan-500 animate-pulse" />
|
||||
<span className="text-slate-700 font-medium tracking-wide">{statusMessage}</span>
|
||||
<div className="flex space-x-1 ml-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-cyan-500 animate-bounce [animation-delay:-0.3s]"></div>
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-cyan-500 animate-bounce [animation-delay:-0.15s]"></div>
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-cyan-500 animate-bounce"></div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="lg"
|
||||
className="px-8 py-6 rounded-full text-lg font-bold shadow-xl shadow-cyan-500/20 hover:shadow-cyan-500/40 hover:-translate-y-1 transition-all"
|
||||
onClick={onStart}
|
||||
>
|
||||
<Play className="w-5 h-5 mr-2 fill-current" />
|
||||
Start Extraction
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes scanline {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CheckCircle2, Loader2, RefreshCw, Sparkles } from 'lucide-react'
|
||||
import { CheckCircle2, Loader2, Sparkles } from 'lucide-react'
|
||||
import React from 'react'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { CandidateAsset, ExtractedAsset } from '../../types'
|
||||
@@ -7,14 +7,12 @@ interface RefineStageProps {
|
||||
selectedCandidates: CandidateAsset[]
|
||||
results: ExtractedAsset[]
|
||||
isComplete: boolean
|
||||
onDone: () => void
|
||||
}
|
||||
|
||||
export const RefineStage: React.FC<RefineStageProps> = ({
|
||||
selectedCandidates,
|
||||
results,
|
||||
isComplete,
|
||||
onDone,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full max-w-6xl mx-auto px-4 py-6">
|
||||
@@ -31,15 +29,6 @@ export const RefineStage: React.FC<RefineStageProps> = ({
|
||||
: `Processing item ${Math.min(results.length + 1, selectedCandidates.length)} of ${selectedCandidates.length}...`}
|
||||
</p>
|
||||
</div>
|
||||
{isComplete && (
|
||||
<button
|
||||
onClick={onDone}
|
||||
className="flex items-center gap-2 px-6 py-2.5 rounded-lg bg-green-500 hover:bg-green-600 text-white font-medium shadow-lg shadow-green-200 transition-all animate-bounce"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Start Over
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
|
||||
@@ -1,46 +1,31 @@
|
||||
import { Check } from 'lucide-react'
|
||||
import React, { useState } from 'react'
|
||||
import React from 'react'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { CandidateAsset } from '../../types'
|
||||
import { Button } from '../ui/button'
|
||||
import { ScrollArea } from '../ui/scroll-area'
|
||||
|
||||
interface SelectionStageProps {
|
||||
candidates: CandidateAsset[]
|
||||
onConfirm: (selectedIndices: number[]) => void
|
||||
onCancel: () => void
|
||||
selectedIndices: number[]
|
||||
onToggleSelection: (index: number) => void
|
||||
}
|
||||
|
||||
export const SelectionStage: React.FC<SelectionStageProps> = ({
|
||||
candidates,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
selectedIndices,
|
||||
onToggleSelection,
|
||||
}) => {
|
||||
// Initialize with all selected by default
|
||||
const [selectedIndices, setSelectedIndices] = useState<number[]>(candidates.map((_, i) => i))
|
||||
|
||||
const toggleSelection = (index: number) => {
|
||||
setSelectedIndices((prev) =>
|
||||
prev.includes(index) ? prev.filter((i) => i !== index) : [...prev, index],
|
||||
)
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (selectedIndices.length === 0) return
|
||||
onConfirm(selectedIndices)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col max-h-[80vh] w-full max-w-6xl mx-auto px-4 py-2">
|
||||
<div className="flex flex-col h-full w-full max-w-6xl mx-auto px-4 py-2">
|
||||
{/* Grid */}
|
||||
<ScrollArea className="h-full">
|
||||
<div className="grid grid-cols-3 gap-2 p-1 h-full">
|
||||
<div className="grid grid-cols-3 gap-2 p-1 h-full pb-6">
|
||||
{candidates.map((item, index) => {
|
||||
const isSelected = selectedIndices.includes(index)
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => toggleSelection(index)}
|
||||
onClick={() => onToggleSelection(index)}
|
||||
className={cn(
|
||||
'group relative rounded-xl overflow-hidden cursor-pointer transition-all duration-300 border-2 bg-white animate-fade-in-up flex-shrink-0',
|
||||
isSelected
|
||||
@@ -85,25 +70,6 @@ export const SelectionStage: React.FC<SelectionStageProps> = ({
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-6 bg-white/80 backdrop-blur-md border-t border-slate-200 z-50">
|
||||
<div className="max-w-6xl mx-auto flex justify-end items-center gap-4">
|
||||
<Button onClick={onCancel}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={selectedIndices.length === 0}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-8 py-3 font-bold uppercase tracking-wide transition-all shadow-lg',
|
||||
selectedIndices.length > 0
|
||||
? 'bg-gradient-to-r from-cyan-500 to-cyan-600 text-white'
|
||||
: 'bg-slate-200 text-slate-400 cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
Start Refining ({selectedIndices.length})
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
@@ -0,0 +1,52 @@
|
||||
from playwright.sync_api import sync_playwright, expect
|
||||
import os
|
||||
|
||||
def run():
|
||||
# Create a dummy image file (1x1 transparent png)
|
||||
with open("dummy.png", "wb") as f:
|
||||
f.write(b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82")
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
context = browser.new_context(viewport={"width": 1280, "height": 720})
|
||||
page = context.new_page()
|
||||
|
||||
print("Navigating to Equipments page...")
|
||||
page.goto("http://localhost:3000/equipments")
|
||||
|
||||
try:
|
||||
page.wait_for_load_state("networkidle", timeout=5000)
|
||||
except:
|
||||
print("Network idle timeout, continuing...")
|
||||
|
||||
print("Opening dialog...")
|
||||
page.get_by_role("button", name="Extract Equipment").click()
|
||||
expect(page.locator("div[role='dialog']")).to_be_visible()
|
||||
|
||||
print("Uploading image...")
|
||||
# Find file input (it has id="file-upload")
|
||||
# In UploadStage.tsx: <input id="file-upload" type="file" ... />
|
||||
# But verify checking if it is hidden
|
||||
# page.set_input_files("#file-upload", "dummy.png")
|
||||
# Or locate by label/id
|
||||
page.locator("#file-upload").set_input_files("dummy.png")
|
||||
|
||||
print("Verifying Analyze Stage Footer...")
|
||||
# Should have Cancel and Start Extraction
|
||||
cancel_btn = page.get_by_role("button", name="Cancel")
|
||||
start_btn = page.get_by_role("button", name="Start Extraction")
|
||||
|
||||
expect(cancel_btn).to_be_visible()
|
||||
expect(start_btn).to_be_visible()
|
||||
|
||||
page.screenshot(path="verification/extractor_dialog_analyze.png")
|
||||
print("Screenshot saved to verification/extractor_dialog_analyze.png")
|
||||
|
||||
browser.close()
|
||||
|
||||
# Cleanup
|
||||
if os.path.exists("dummy.png"):
|
||||
os.remove("dummy.png")
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -0,0 +1,48 @@
|
||||
from playwright.sync_api import sync_playwright, expect
|
||||
|
||||
def run():
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
context = browser.new_context(viewport={"width": 1280, "height": 720})
|
||||
page = context.new_page()
|
||||
|
||||
print("Navigating to Equipments page...")
|
||||
page.goto("http://localhost:3000/equipments")
|
||||
|
||||
# Wait for potential loading
|
||||
try:
|
||||
page.wait_for_load_state("networkidle", timeout=5000)
|
||||
except:
|
||||
print("Network idle timeout, continuing...")
|
||||
|
||||
print("Looking for Extract Equipment button...")
|
||||
# The button text is "Extract Equipment"
|
||||
# It might take time to render if data is fetching
|
||||
extract_btn = page.get_by_role("button", name="Extract Equipment")
|
||||
expect(extract_btn).to_be_visible(timeout=15000)
|
||||
|
||||
print("Clicking Extract Equipment...")
|
||||
extract_btn.click()
|
||||
|
||||
# Wait for dialog
|
||||
dialog = page.locator("div[role='dialog']")
|
||||
expect(dialog).to_be_visible()
|
||||
|
||||
print("Verifying Upload Stage Footer...")
|
||||
# The dialog should have a footer with a Cancel button
|
||||
# DialogFooter is just a div, but it should contain "Cancel" button
|
||||
cancel_btn = dialog.get_by_role("button", name="Cancel")
|
||||
expect(cancel_btn).to_be_visible()
|
||||
|
||||
# Take screenshot of Upload Stage
|
||||
page.screenshot(path="verification/extractor_dialog_upload.png")
|
||||
print("Screenshot saved to verification/extractor_dialog_upload.png")
|
||||
|
||||
# Verify content area (UploadStage)
|
||||
upload_area = page.get_by_text("Upload an Image")
|
||||
expect(upload_area).to_be_visible()
|
||||
|
||||
browser.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
Reference in new issue
Block a user