Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion frontend/src/api/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { TranslateRequest, TranslateResponse, SheetsResponse, FeedbackRequest, FeedbackResponse } from '../types';
import type { TranslateRequest, TranslateResponse, SheetsResponse, FeedbackRequest, FeedbackResponse, EstimateResponse } from '../types';
import { generateOutputFilename } from '../lib/utils';

const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
Expand Down Expand Up @@ -36,6 +36,48 @@ export async function getSheets(file: File): Promise<SheetsResponse> {
}
}

export async function getEstimate(file: File, sheets?: string[]): Promise<EstimateResponse> {
const formData = new FormData();
formData.append('file', file);

if (sheets && sheets.length > 0) {
formData.append('sheets', sheets.join(','));
}

try {
const response = await fetch(`${API_URL}/estimate`, {
method: 'POST',
body: formData,
});

if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const errorMessage = errorData.detail || `Failed to get estimate with status ${response.status}`;
return {
success: false,
error: errorMessage,
};
}

const data = await response.json();
return {
success: true,
estimate: {
cellCount: data.cell_count,
estimatedCostUsd: data.estimated_cost_usd,
estimatedTimeSeconds: data.estimated_time_seconds,
estimatedTimeDisplay: data.estimated_time_display,
},
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Network error occurred';
return {
success: false,
error: errorMessage,
};
}
}

export async function translateFile(request: TranslateRequest): Promise<TranslateResponse> {
const formData = new FormData();

Expand Down
85 changes: 65 additions & 20 deletions frontend/src/components/features/translate/ProgressIndicator.tsx
Original file line number Diff line number Diff line change
@@ -1,41 +1,83 @@
import { motion, AnimatePresence } from 'framer-motion';
import { FileText, Languages, CheckCircle2 } from 'lucide-react';
import type { TranslateStatus } from '../../../types';
import type { TranslateStatus, TranslationProgress } from '../../../types';
import './Progress.css';

interface ProgressIndicatorProps {
status: TranslateStatus;
progress?: TranslationProgress | null;
}

const stages = [
{
key: 'uploading' as const,
{
key: 'uploading' as const,
label: 'Uploading file',
icon: FileText,
progress: 33
baseProgress: 10
},
{
key: 'translating' as const,
{
key: 'translating' as const,
label: 'Translating content',
icon: Languages,
progress: 66
baseProgress: 50
},
{
key: 'success' as const,
{
key: 'success' as const,
label: 'Complete',
icon: CheckCircle2,
progress: 100
baseProgress: 100
},
];

export function ProgressIndicator({ status }: ProgressIndicatorProps) {
function getStageLabel(stage: string): string {
switch (stage) {
case 'extracting':
return 'Extracting cells...';
case 'translating':
return 'Translating...';
case 'rich_text':
return 'Processing rich text...';
case 'dropdowns':
return 'Translating dropdowns...';
case 'writing':
return 'Writing file...';
case 'complete':
return 'Complete!';
default:
return 'Processing...';
}
}

export function ProgressIndicator({ status, progress }: ProgressIndicatorProps) {
if (status === 'idle' || status === 'error') {
return null;
}

const currentStage = stages.find(s => s.key === status) || stages[0];
// Calculate progress percentage
let displayProgress: number;
if (status === 'uploading') {
displayProgress = 5;
} else if (status === 'translating' && progress) {
// Map the translation progress (0-100) to 10-95 range
displayProgress = Math.max(10, Math.min(95, 10 + (progress.percentage * 0.85)));
} else if (status === 'success') {
displayProgress = 100;
} else {
displayProgress = 10;
}

const currentIndex = stages.findIndex(s => s.key === status);
const progress = currentStage.progress;

// Build the label for translating status
let translatingLabel = 'Translating content';
if (status === 'translating' && progress) {
const stageText = getStageLabel(progress.stage);
if (progress.total > 0) {
translatingLabel = `${stageText} ${progress.translated}/${progress.total} cells`;
} else {
translatingLabel = stageText;
}
}

return (
<motion.div
Expand All @@ -51,8 +93,8 @@ export function ProgressIndicator({ status }: ProgressIndicatorProps) {
<motion.div
className="progress-bar-fill"
initial={{ width: '0%' }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.8, ease: [0.4, 0, 0.2, 1] }}
animate={{ width: `${displayProgress}%` }}
transition={{ duration: 0.3, ease: [0.4, 0, 0.2, 1] }}
/>
<motion.div
className="progress-bar-shine"
Expand All @@ -69,12 +111,12 @@ export function ProgressIndicator({ status }: ProgressIndicatorProps) {
</div>
<div className="progress-percentage">
<motion.span
key={progress}
key={Math.round(displayProgress)}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3 }}
>
{progress}%
{Math.round(displayProgress)}%
</motion.span>
</div>
</div>
Expand All @@ -86,12 +128,15 @@ export function ProgressIndicator({ status }: ProgressIndicatorProps) {
const isComplete = index < currentIndex || status === 'success';
const Icon = stage.icon;

// Use dynamic label for translating stage
const label = stage.key === 'translating' && isActive ? translatingLabel : stage.label;

return (
<motion.div
key={stage.key}
className={`progress-stage ${isActive ? 'active' : ''} ${isComplete ? 'complete' : ''}`}
initial={{ opacity: 0.4, y: 5 }}
animate={{
animate={{
opacity: isActive || isComplete ? 1 : 0.4,
y: 0,
}}
Expand All @@ -116,7 +161,7 @@ export function ProgressIndicator({ status }: ProgressIndicatorProps) {
animate={{ opacity: 1 }}
transition={{ delay: 0.2 + index * 0.1 }}
>
{stage.label}
{label}
</motion.span>
</motion.div>
);
Expand All @@ -125,7 +170,7 @@ export function ProgressIndicator({ status }: ProgressIndicatorProps) {

{/* Status hint */}
<AnimatePresence>
{status === 'translating' && (
{status === 'translating' && !progress && (
<motion.p
className="progress-hint"
initial={{ opacity: 0, y: -5 }}
Expand Down
61 changes: 55 additions & 6 deletions frontend/src/components/features/translate/TranslateForm.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Upload, FileSpreadsheet, Sparkles, ArrowRight, Shield, Settings2, MessageSquare, ChevronDown } from 'lucide-react';
import { Upload, FileSpreadsheet, Sparkles, ArrowRight, Shield, Settings2, MessageSquare, ChevronDown, Grid3X3 } from 'lucide-react';
import { Button, Recaptcha, FloatingFeedbackButton, type RecaptchaRef } from '../../ui';
import { SheetSelector } from './SheetSelector';
import { ResultDisplay } from './ResultDisplay';
import { ProgressIndicator } from './ProgressIndicator';
import { useTranslate } from '../../../hooks/useTranslate';
import { getSheets } from '../../../api/client';
import { getSheets, getEstimate } from '../../../api/client';
import { languages } from '../../../lib/languages';
import type { FileInfo } from '../../../types';
import type { FileInfo, EstimateResult } from '../../../types';
import './Translate.css';
import './Progress.css';

Expand All @@ -21,19 +21,22 @@ export function TranslateForm() {
const [selectedSheets, setSelectedSheets] = useState<string[]>([]);
const [showAdvanced, setShowAdvanced] = useState(false);
const [loadingSheets, setLoadingSheets] = useState(false);
const [estimate, setEstimate] = useState<EstimateResult | null>(null);
const [loadingEstimate, setLoadingEstimate] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
const recaptchaRef = useRef<RecaptchaRef>(null);
const containerRef = useRef<HTMLDivElement>(null);

const { status, error, filename, translate, downloadResult, reset } = useTranslate();
const { status, error, filename, progress, translate, downloadResult, reset } = useTranslate();

// Fetch sheets when file is selected
useEffect(() => {
async function fetchSheets() {
if (!selectedFile) {
setSheets([]);
setSelectedSheets([]);
setEstimate(null);
return;
}

Expand All @@ -52,6 +55,34 @@ export function TranslateForm() {
fetchSheets();
}, [selectedFile]);

// Fetch estimate when file or selected sheets change
useEffect(() => {
async function fetchEstimate() {
if (!selectedFile) {
setEstimate(null);
return;
}

// Wait for sheets to be loaded before fetching estimate
if (loadingSheets) return;

setLoadingEstimate(true);
const sheetsToEstimate = selectedSheets.length > 0 && selectedSheets.length < sheets.length
? selectedSheets
: undefined;
const response = await getEstimate(selectedFile.file, sheetsToEstimate);
setLoadingEstimate(false);

if (response.success && response.estimate) {
setEstimate(response.estimate);
} else {
setEstimate(null);
}
}

fetchEstimate();
}, [selectedFile, selectedSheets, sheets.length, loadingSheets]);

const handleFileSelect = useCallback((fileInfo: FileInfo | null) => {
setSelectedFile(fileInfo);
if (status !== 'idle') {
Expand Down Expand Up @@ -328,7 +359,7 @@ export function TranslateForm() {
disabled={isTranslating}
/>
<p className="text-xs text-gray-500 dark:text-gray-400 leading-relaxed">
Provide domain-specific context to help the AI understand your document better for more accurate translations.
Provide domain-specific context for more accurate translations.
</p>
</div>
</div>
Expand Down Expand Up @@ -368,13 +399,31 @@ export function TranslateForm() {
)}
</Button>

{/* Estimation Display */}
{selectedFile && (estimate || loadingEstimate) && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="mt-4 flex items-center justify-center gap-6 text-sm"
>
{loadingEstimate ? (
<span className="text-gray-500 dark:text-gray-400">Analyzing file...</span>
) : estimate ? (
<div className="flex items-center gap-1.5 text-gray-600 dark:text-gray-400">
<Grid3X3 className="w-4 h-4 text-emerald-600 dark:text-emerald-400" />
<span>{estimate.cellCount.toLocaleString()} cells to translate</span>
</div>
) : null}
</motion.div>
)}

<p className="text-center text-sm text-gray-500 mt-4">
Supports files up to 50MB
</p>

{/* Progress Indicator */}
<AnimatePresence>
{isTranslating && <ProgressIndicator status={status} />}
{isTranslating && <ProgressIndicator status={status} progress={progress} />}
</AnimatePresence>

{/* Result Display */}
Expand Down
Loading