Major changes across backend, frontend, infrastructure: - Provider system with model selection (Google, DeepL, OpenAI, Ollama, Google Cloud) - Admin panel: user management, pricing, settings - Glossary system with CSV import/export - Subscription and tier quota management - Security hardening (rate limiting, API key auth, path traversal fixes) - Docker compose for dev, prod, and IONOS deployment - Alembic migrations for new tables - Frontend: dashboard, pricing page, landing page, i18n (en/fr) - Test suite and verification scripts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
19 KiB
Story 4.6: Page Translation - Upload
Status: done
Story
En tant qu'utilisateur, Je veux uploader un document via drag-and-drop, de sorte que je puisse démarrer une traduction.
Acceptance Criteria
- Route:
/dashboard/translateaffiche la page d'upload - Drop Zone: Drag-and-drop fonctionnel + click to browse
- Validation Format: Seuls .xlsx, .docx, .pptx acceptés (magic bytes check si possible, sinon extension)
- Erreur Format: Message "Format non supporté. Formats acceptés : .xlsx, .docx, .pptx"
- File Preview: Affiche nom du fichier + taille + icône selon type
- Remove File: Bouton X pour supprimer le fichier et reset l'état
- Max Size: 50 MB max → erreur "Fichier trop volumineux (max 50 MB)"
- Branding: Utiliser icône
Languagesde Lucide + "Office Translator" (PAS "Translate Co." ni "文A") - Merge Directive: UI depuis
office-translator-landing-page/components/translation-card.tsx, logique API depuisfrontend/src/components/file-uploader.tsx - Colocation: Composants/hooks/types dans
frontend/src/app/dashboard/translate/
Tasks / Subtasks
-
Task 1: Créer la structure de la page (AC: #1, #10)
- 1.1 Créer
frontend/src/app/dashboard/translate/page.tsx— Client Component - 1.2 Créer
frontend/src/app/dashboard/translate/types.ts— Types FileUpload - 1.3 Layout hérite de
frontend/src/app/dashboard/layout.tsx(Story 4.5)
- 1.1 Créer
-
Task 2: Créer FileDropZone.tsx (AC: #2, #3, #4, #7)
- 2.1 Créer
frontend/src/app/dashboard/translate/FileDropZone.tsx(colocated) - 2.2 Implémenter drag-over state avec highlight visuel
- 2.3 Implémenter file input hidden + click to browse
- 2.4 Valider extension (.xlsx, .docx, .pptx)
- 2.5 Valider taille max 50 MB
- 2.6 Afficher erreur format/taille en dessous de la zone
- 2.7 Copier styles depuis
translation-card.tsx(drop zone section)
- 2.1 Créer
-
Task 3: Créer FilePreview.tsx (AC: #5, #6)
- 3.1 Créer
frontend/src/app/dashboard/translate/FilePreview.tsx(colocated) - 3.2 Afficher icône selon type (FileSpreadsheet/FileText/Presentation)
- 3.3 Afficher nom du fichier (truncate si trop long)
- 3.4 Afficher taille formatée (KB/MB)
- 3.5 Bouton X pour remove → reset state
- 3.6 Copier styles depuis
translation-card.tsx(file preview section)
- 3.1 Créer
-
Task 4: Créer useFileUpload.ts hook (AC: #2, #3, #4, #7)
- 4.1 Créer
frontend/src/app/dashboard/translate/useFileUpload.ts(colocated) - 4.2 State: file, error, isDragOver
- 4.3 Handlers: handleDrop, handleDragOver, handleDragLeave, handleFileSelect, removeFile
- 4.4 Validation function: validateFile(file) → error | null
- 4.5 Return: { file, error, isDragOver, handlers... }
- 4.1 Créer
-
Task 5: Créer la page translate/page.tsx (AC: #1, #8)
- 5.1 Page heading: "Translate Document"
- 5.2 Intégrer FileDropZone + FilePreview
- 5.3 Afficher erreur si validation échoue
- 5.4 Message helper: "Supported formats: Excel (.xlsx), Word (.docx), PowerPoint (.pptx)"
- 5.5 Note privacy: "Files are automatically deleted after 60 minutes"
-
Task 6: Vérifier le build (AC: Tous)
- 6.1
npm run build→ 0 erreurs TypeScript - 6.2 Tester drag-and-drop
- 6.3 Tester click to browse
- 6.4 Tester validation format (essayer .pdf → erreur)
- 6.5 Tester validation taille (fichier > 50 MB → erreur)
- 6.6 Tester remove file → state reset
- 6.1
Dev Notes
🏗️ Stack Technique
| Technologie | Version |
|---|---|
| Next.js | 16.0.6 (App Router) |
| React | 19.2.0 |
| TanStack Query | v5.90.21 |
| Tailwind CSS | configuré |
| Lucide React | disponible |
| shadcn/ui | Button, Card, Progress |
| react-dropzone | ⚠️ DÉJÀ INSTALLÉ dans frontend/ |
📁 Structure Cible (Colocation Pattern)
frontend/src/app/dashboard/translate/
├── page.tsx # 🆕 Page principale
├── FileDropZone.tsx # 🆕 Zone drag-and-drop
├── FilePreview.tsx # 🆕 Preview fichier uploadé
├── useFileUpload.ts # 🆕 Hook validation + state
└── types.ts # 🆕 Types FileUpload
⚠️ Règle absolue (architecture.md):
🚨 FICHIERS SPÉCIAUX: page.tsx, layout.tsx → TOUJOURS minuscules
🚨 COLOCATION: Components/hooks/types dans le dossier de leur page
🔗 MERGE DIRECTIVE — Sources à Fusionner
UI Components depuis office-translator-landing-page/:
| Source | Fichier | Usage |
|---|---|---|
| Drop Zone UI | office-translator-landing-page/components/translation-card.tsx |
Structure, styles, animation drag-over |
| File Preview | translation-card.tsx lignes 154-198 |
Preview avec icône, nom, taille, bouton X |
| Error styling | translation-card.tsx |
Style erreur format non supporté |
Logique Validation depuis frontend/:
| Source | Fichier | Usage |
|---|---|---|
| Dropzone logic | frontend/src/components/file-uploader.tsx lignes 217-228 |
react-dropzone config, acceptedTypes |
| File validation | file-uploader.tsx |
Validation format, taille |
| FilePreview component | file-uploader.tsx lignes 56-184 |
Preview avancé (à simplifier) |
⚠️ CRITIQUE: Formats Acceptés
MIME Types (depuis file-uploader.tsx):
const ACCEPTED_TYPES = {
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [".xlsx"],
"application/vnd.ms-excel": [".xls"], // Optionnel: support ancien format
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": [".docx"],
"application/msword": [".doc"], // Optionnel
"application/vnd.openxmlformats-officedocument.presentationml.presentation": [".pptx"],
"application/vnd.ms-powerpoint": [".ppt"], // Optionnel
};
⚠️ PRD FR50: Seuls .xlsx, .docx, .pptx sont requis. Les anciens formats (.xls, .doc, .ppt) sont optionnels.
🔧 Types TypeScript à Créer
// frontend/src/app/dashboard/translate/types.ts
export type SupportedFormat = 'xlsx' | 'docx' | 'pptx';
export interface FileUploadState {
file: File | null;
error: string | null;
isDragOver: boolean;
}
export interface FileUploadActions {
handleDrop: (e: React.DragEvent) => void;
handleDragOver: (e: React.DragEvent) => void;
handleDragLeave: (e: React.DragEvent) => void;
handleFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
removeFile: () => void;
}
export interface UseFileUploadReturn extends FileUploadState, FileUploadActions {}
🔧 useFileUpload.ts — Hook à Créer
// frontend/src/app/dashboard/translate/useFileUpload.ts
'use client';
import { useState, useCallback } from 'react';
import type { UseFileUploadReturn } from './types';
const ACCEPTED_EXTENSIONS = ['xlsx', 'docx', 'pptx'];
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
export function useFileUpload(): UseFileUploadReturn {
const [file, setFile] = useState<File | null>(null);
const [error, setError] = useState<string | null>(null);
const [isDragOver, setIsDragOver] = useState(false);
const validateFile = useCallback((file: File): string | null => {
const ext = file.name.split('.').pop()?.toLowerCase();
if (!ext || !ACCEPTED_EXTENSIONS.includes(ext)) {
return 'Format non supporté. Formats acceptés : .xlsx, .docx, .pptx';
}
if (file.size > MAX_FILE_SIZE) {
return 'Fichier trop volumineux (max 50 MB)';
}
return null;
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
const droppedFile = e.dataTransfer.files[0];
if (droppedFile) {
const validationError = validateFile(droppedFile);
if (validationError) {
setError(validationError);
setFile(null);
} else {
setFile(droppedFile);
setError(null);
}
}
}, [validateFile]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
}, []);
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const selected = e.target.files?.[0];
if (selected) {
const validationError = validateFile(selected);
if (validationError) {
setError(validationError);
setFile(null);
} else {
setFile(selected);
setError(null);
}
}
}, [validateFile]);
const removeFile = useCallback(() => {
setFile(null);
setError(null);
}, []);
return {
file,
error,
isDragOver,
handleDrop,
handleDragOver,
handleDragLeave,
handleFileSelect,
removeFile,
};
}
🔧 FileDropZone.tsx — Pattern à Suivre
Copier depuis: office-translator-landing-page/components/translation-card.tsx lignes 141-199
// frontend/src/app/dashboard/translate/FileDropZone.tsx
'use client';
import { useCallback, useRef } from 'react';
import { Upload, FileCheck, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { UseFileUploadReturn } from './types';
interface FileDropZoneProps {
upload: UseFileUploadReturn;
}
export function FileDropZone({ upload }: FileDropZoneProps) {
const inputRef = useRef<HTMLInputElement>(null);
const handleClick = () => {
inputRef.current?.click();
};
return (
<div
className={cn(
"relative flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed px-6 py-10 transition-colors cursor-pointer",
upload.isDragOver
? "border-accent bg-accent/5"
: upload.file
? "border-success/40 bg-success/5"
: "border-border bg-muted/30 hover:border-muted-foreground/30"
)}
onDragOver={upload.handleDragOver}
onDragLeave={upload.handleDragLeave}
onDrop={upload.handleDrop}
onClick={handleClick}
>
{/* ... contenu similaire à translation-card.tsx */}
<input
ref={inputRef}
type="file"
accept=".xlsx,.docx,.pptx"
className="hidden"
onChange={upload.handleFileSelect}
/>
</div>
);
}
🔧 FilePreview.tsx — Pattern à Suivre
Copier depuis: office-translator-landing-page/components/translation-card.tsx lignes 154-176
// frontend/src/app/dashboard/translate/FilePreview.tsx
'use client';
import { FileSpreadsheet, FileText, Presentation, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
const FILE_ICONS: Record<string, React.ElementType> = {
xlsx: FileSpreadsheet,
docx: FileText,
pptx: Presentation,
};
interface FilePreviewProps {
file: File;
onRemove: () => void;
}
export function FilePreview({ file, onRemove }: FilePreviewProps) {
const ext = file.name.split('.').pop()?.toLowerCase() || '';
const FileIcon = FILE_ICONS[ext] || FileText;
const formatSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
return (
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-lg bg-secondary">
<FileIcon className="size-5 text-foreground" />
</div>
<div className="flex flex-col">
<span className="text-sm font-medium text-foreground truncate max-w-xs">
{file.name}
</span>
<span className="text-xs text-muted-foreground">
{formatSize(file.size)} · .{ext}
</span>
</div>
<Button
variant="ghost"
size="icon-sm"
className="ml-2 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
>
<X className="size-4" />
</Button>
</div>
);
}
🔧 page.tsx — Structure de la Page
// frontend/src/app/dashboard/translate/page.tsx
'use client';
import { FileText, ShieldCheck, Clock } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { FileDropZone } from './FileDropZone';
import { FilePreview } from './FilePreview';
import { useFileUpload } from './useFileUpload';
export default function TranslatePage() {
const upload = useFileUpload();
return (
<div className="mx-auto max-w-xl px-4 py-6 lg:px-8">
<Card className="border-border/70 shadow-lg">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="size-5" />
Translate Document
</CardTitle>
<CardDescription>
Upload an Excel, Word, or PowerPoint file to translate
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{/* Drop Zone / Preview */}
{upload.file ? (
<FilePreview file={upload.file} onRemove={upload.removeFile} />
) : (
<FileDropZone upload={upload} />
)}
{/* Error Message */}
{upload.error && (
<p className="text-sm text-destructive">{upload.error}</p>
)}
{/* Helper Text */}
<p className="text-xs text-muted-foreground">
Supported formats: Excel (.xlsx), Word (.docx), PowerPoint (.pptx)
</p>
</CardContent>
</Card>
{/* Privacy Badge */}
<div className="flex items-center justify-center gap-4 mt-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<ShieldCheck className="size-3.5" />
<span>Zero Data Retention</span>
</div>
<div className="h-3 w-px bg-border" />
<div className="flex items-center gap-1.5">
<Clock className="size-3.5" />
<span>Files deleted after 60 min</span>
</div>
</div>
</div>
);
}
🚨 Anti-Patterns à Éviter
- NE PAS utiliser react-dropzone pour cette story — Utiliser les handlers natifs drag events (plus simple, moins de dépendances)
- NE PAS hardcoder le message d'erreur → utiliser une constante
- NE PAS oublier le
e.stopPropagation()sur le bouton remove - NE PAS créer de fichiers globaux — tout est colocated
- NE PAS utiliser "Translate Co." / "文A" → utiliser "Office Translator"
- NE PAS afficher le preview si error est présent
🔗 Cohérence Cross-Story (Context Continuité)
Story 4.5 (Dashboard Layout) a établi:
- Layout dashboard avec sidebar
- Route
/dashboard/* - Auth check via
useUser()hook - Pattern colocation
Cette story (4.6) DOIT:
- Hériter du layout dashboard existant
- Suivre le même pattern colocation
- Utiliser les mêmes composants UI shadcn/ui
Stories suivantes (4.7, 4.8) utiliseront:
- Le fichier uploadé depuis cette page
- L'état de traduction persisté
📊 File Size Format
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
🔍 Composants Réutilisables Existants
| Composant | Chemin | Usage |
|---|---|---|
Button |
@/components/ui/button |
variant="ghost", size="icon-sm" |
Card |
@/components/ui/card |
Card, CardHeader, CardTitle, CardDescription, CardContent |
Lucide icons |
lucide-react |
Upload, FileText, FileSpreadsheet, Presentation, X, ShieldCheck, Clock |
🧪 Tests à Effectuer
- Build:
npm run builddansfrontend/→ 0 erreurs TypeScript/ESLint - Drag-and-drop: Déposer un fichier .xlsx → preview s'affiche
- Click to browse: Cliquer → file dialog s'ouvre
- Validation format: Déposer un .pdf → erreur "Format non supporté"
- Validation taille: Déposer un fichier > 50 MB → erreur "Fichier trop volumineux"
- Remove file: Cliquer X → file reset, error cleared
- Navigation: Sidebar link "Translate" →
/dashboard/translate
Project Structure Notes
Fichiers à créer:
frontend/src/app/dashboard/translate/page.tsx— Page principalefrontend/src/app/dashboard/translate/FileDropZone.tsx— Zone drag-and-dropfrontend/src/app/dashboard/translate/FilePreview.tsx— Preview fichierfrontend/src/app/dashboard/translate/useFileUpload.ts— Hook state + validationfrontend/src/app/dashboard/translate/types.ts— Types TypeScript
⚠️ Pas de fichiers globaux à créer — tout est colocated.
⚠️ Pas besoin de créer un layout — hérite de frontend/src/app/dashboard/layout.tsx (Story 4.5).
References
- [Source: _bmad-output/planning-artifacts/epics.md#Story-4.6] — Story requirements
- [Source: _bmad-output/planning-artifacts/architecture.md#Frontend-Architecture] — TanStack Query + colocation
- [Source: _bmad-output/planning-artifacts/architecture.md#Naming-Patterns] — Conventions nommage
- [Source: _bmad-output/planning-artifacts/architecture.md#API-Response-Formats] — Format réponse API
- [Source: office-translator-landing-page/components/translation-card.tsx] — UI drop zone à copier
- [Source: frontend/src/components/file-uploader.tsx] — Logique validation, react-dropzone config
- [Source: frontend/src/lib/apiClient.ts] — Client API centralisé (pas utilisé dans cette story)
- [Source: _bmad-output/implementation-artifacts/4-5-dashboard-layout.md] — Pattern colocation, context dashboard
Dev Agent Record
Agent Model Used
Claude Sonnet 4 (claude-sonnet-4@20250514)
Debug Log References
N/A
Completion Notes List
- ✅ Créé types.ts avec FileUploadState, FileUploadActions, UseFileUploadReturn
- ✅ Créé useFileUpload.ts hook avec validation format (.xlsx, .docx, .pptx) et taille (50 MB max)
- ✅ Créé FileDropZone.tsx avec drag-and-drop natif + click to browse
- ✅ Créé FilePreview.tsx avec icône dynamique selon type, taille formatée, bouton remove
- ✅ Créé page.tsx intégrant tous les composants avec Card, error display, privacy badges
- ✅ Build Next.js réussi (0 erreurs TypeScript)
- ✅ Route /dashboard/translate fonctionnelle
Senior Developer Review (AI)
Date: 2026-02-23 Reviewer: Code Review Workflow
Issues Fixed:
- 🔴 [HIGH] AC #8: Icône
FileText→Languages, titre "Translate Document" → "Office Translator" - 🔴 [HIGH]
removeFile()reset maintenantisDragOverstate - 🟡 [MEDIUM] Input hidden au lieu de
absolute inset-0 opacity-0(meilleure UX) - 🟡 [MEDIUM] Supprimé
'use client'inutile du hook - 🟡 [MEDIUM] Supprimé
onClick={() => {}}mort dans page.tsx - 🟡 [LOW] Messages d'erreur centralisés dans
ERROR_MESSAGESconstant
Outcome: ✅ Approved — All issues fixed
File List
Fichiers créés:
- frontend/src/app/dashboard/translate/page.tsx
- frontend/src/app/dashboard/translate/types.ts
- frontend/src/app/dashboard/translate/useFileUpload.ts
- frontend/src/app/dashboard/translate/FileDropZone.tsx
- frontend/src/app/dashboard/translate/FilePreview.tsx
Fichiers modifiés:
- _bmad-output/implementation-artifacts/sprint-status.yaml (ready-for-dev → in-progress → review)