Merge pull request #37 from Yiling-J/extractor-dialog-footer-17605281711190367568

Refactor ExtractorDialog footer layout
This commit is contained in:
Yiling-J authored and GitHub committed 2026-02-16 19:39:13 +08:00
commit 413b24e2c1
8 files changed
+204 -110

No files matched your search

+94 -22
View File
@@ -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 -34
View File
@@ -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 -12
View File
@@ -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 */}
+8 -42
View File
@@ -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

+52
View File
@@ -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()
+48
View File
@@ -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()