# 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 1. **Route**: `/dashboard/translate` affiche la page d'upload 2. **Drop Zone**: Drag-and-drop fonctionnel + click to browse 3. **Validation Format**: Seuls .xlsx, .docx, .pptx acceptés (magic bytes check si possible, sinon extension) 4. **Erreur Format**: Message "Format non supporté. Formats acceptés : .xlsx, .docx, .pptx" 5. **File Preview**: Affiche nom du fichier + taille + icône selon type 6. **Remove File**: Bouton X pour supprimer le fichier et reset l'état 7. **Max Size**: 50 MB max → erreur "Fichier trop volumineux (max 50 MB)" 8. **Branding**: Utiliser icône `Languages` de Lucide + "Office Translator" (PAS "Translate Co." ni "文A") 9. **Merge Directive**: UI depuis `office-translator-landing-page/components/translation-card.tsx`, logique API depuis `frontend/src/components/file-uploader.tsx` 10. **Colocation**: Composants/hooks/types dans `frontend/src/app/dashboard/translate/` ## Tasks / Subtasks - [x] **Task 1: Créer la structure de la page** (AC: #1, #10) - [x] 1.1 Créer `frontend/src/app/dashboard/translate/page.tsx` — Client Component - [x] 1.2 Créer `frontend/src/app/dashboard/translate/types.ts` — Types FileUpload - [x] 1.3 Layout hérite de `frontend/src/app/dashboard/layout.tsx` (Story 4.5) - [x] **Task 2: Créer FileDropZone.tsx** (AC: #2, #3, #4, #7) - [x] 2.1 Créer `frontend/src/app/dashboard/translate/FileDropZone.tsx` (colocated) - [x] 2.2 Implémenter drag-over state avec highlight visuel - [x] 2.3 Implémenter file input hidden + click to browse - [x] 2.4 Valider extension (.xlsx, .docx, .pptx) - [x] 2.5 Valider taille max 50 MB - [x] 2.6 Afficher erreur format/taille en dessous de la zone - [x] 2.7 Copier styles depuis `translation-card.tsx` (drop zone section) - [x] **Task 3: Créer FilePreview.tsx** (AC: #5, #6) - [x] 3.1 Créer `frontend/src/app/dashboard/translate/FilePreview.tsx` (colocated) - [x] 3.2 Afficher icône selon type (FileSpreadsheet/FileText/Presentation) - [x] 3.3 Afficher nom du fichier (truncate si trop long) - [x] 3.4 Afficher taille formatée (KB/MB) - [x] 3.5 Bouton X pour remove → reset state - [x] 3.6 Copier styles depuis `translation-card.tsx` (file preview section) - [x] **Task 4: Créer useFileUpload.ts hook** (AC: #2, #3, #4, #7) - [x] 4.1 Créer `frontend/src/app/dashboard/translate/useFileUpload.ts` (colocated) - [x] 4.2 State: file, error, isDragOver - [x] 4.3 Handlers: handleDrop, handleDragOver, handleDragLeave, handleFileSelect, removeFile - [x] 4.4 Validation function: validateFile(file) → error | null - [x] 4.5 Return: { file, error, isDragOver, handlers... } - [x] **Task 5: Créer la page translate/page.tsx** (AC: #1, #8) - [x] 5.1 Page heading: "Translate Document" - [x] 5.2 Intégrer FileDropZone + FilePreview - [x] 5.3 Afficher erreur si validation échoue - [x] 5.4 Message helper: "Supported formats: Excel (.xlsx), Word (.docx), PowerPoint (.pptx)" - [x] 5.5 Note privacy: "Files are automatically deleted after 60 minutes" - [x] **Task 6: Vérifier le build** (AC: Tous) - [x] 6.1 `npm run build` → 0 erreurs TypeScript - [x] 6.2 Tester drag-and-drop - [x] 6.3 Tester click to browse - [x] 6.4 Tester validation format (essayer .pdf → erreur) - [x] 6.5 Tester validation taille (fichier > 50 MB → erreur) - [x] 6.6 Tester remove file → state reset ## 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):** ```typescript 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 ```typescript // 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) => void; removeFile: () => void; } export interface UseFileUploadReturn extends FileUploadState, FileUploadActions {} ``` ### 🔧 useFileUpload.ts — Hook à Créer ```typescript // 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(null); const [error, setError] = useState(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) => { 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 ```typescript // 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(null); const handleClick = () => { inputRef.current?.click(); }; return (
{/* ... contenu similaire à translation-card.tsx */}
); } ``` ### 🔧 FilePreview.tsx — Pattern à Suivre **Copier depuis:** `office-translator-landing-page/components/translation-card.tsx` lignes 154-176 ```typescript // 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 = { 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 (
{file.name} {formatSize(file.size)} · .{ext}
); } ``` ### 🔧 page.tsx — Structure de la Page ```typescript // 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 (
Translate Document Upload an Excel, Word, or PowerPoint file to translate {/* Drop Zone / Preview */} {upload.file ? ( ) : ( )} {/* Error Message */} {upload.error && (

{upload.error}

)} {/* Helper Text */}

Supported formats: Excel (.xlsx), Word (.docx), PowerPoint (.pptx)

{/* Privacy Badge */}
Zero Data Retention
Files deleted after 60 min
); } ``` ### 🚨 Anti-Patterns à Éviter 1. **NE PAS utiliser react-dropzone** pour cette story — Utiliser les handlers natifs drag events (plus simple, moins de dépendances) 2. **NE PAS hardcoder** le message d'erreur → utiliser une constante 3. **NE PAS oublier** le `e.stopPropagation()` sur le bouton remove 4. **NE PAS créer** de fichiers globaux — tout est colocated 5. **NE PAS utiliser** "Translate Co." / "文A" → utiliser "Office Translator" 6. **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 ```typescript 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 1. **Build**: `npm run build` dans `frontend/` → 0 erreurs TypeScript/ESLint 2. **Drag-and-drop**: Déposer un fichier .xlsx → preview s'affiche 3. **Click to browse**: Cliquer → file dialog s'ouvre 4. **Validation format**: Déposer un .pdf → erreur "Format non supporté" 5. **Validation taille**: Déposer un fichier > 50 MB → erreur "Fichier trop volumineux" 6. **Remove file**: Cliquer X → file reset, error cleared 7. **Navigation**: Sidebar link "Translate" → `/dashboard/translate` ### Project Structure Notes **Fichiers à créer:** - `frontend/src/app/dashboard/translate/page.tsx` — Page principale - `frontend/src/app/dashboard/translate/FileDropZone.tsx` — Zone drag-and-drop - `frontend/src/app/dashboard/translate/FilePreview.tsx` — Preview fichier - `frontend/src/app/dashboard/translate/useFileUpload.ts` — Hook state + validation - `frontend/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:** 1. 🔴 [HIGH] AC #8: Icône `FileText` → `Languages`, titre "Translate Document" → "Office Translator" 2. 🔴 [HIGH] `removeFile()` reset maintenant `isDragOver` state 3. 🟡 [MEDIUM] Input hidden au lieu de `absolute inset-0 opacity-0` (meilleure UX) 4. 🟡 [MEDIUM] Supprimé `'use client'` inutile du hook 5. 🟡 [MEDIUM] Supprimé `onClick={() => {}}` mort dans page.tsx 6. 🟡 [LOW] Messages d'erreur centralisés dans `ERROR_MESSAGES` constant **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)