'use client' import { useState, useCallback, useRef } from 'react' import { useLocale, useTranslations } from 'next-intl' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Upload, FileText, ImageIcon, X, Loader2 } from 'lucide-react' export interface UploadedFile { id?: string file: File status: 'pending' | 'uploading' | 'uploaded' | 'error' error?: string fileName: string fileSize: number /** Unique key to track this upload (handles duplicate filenames) */ uploadKey: string } interface DocumentUploadZoneProps { files: UploadedFile[] onFilesChange: (files: UploadedFile[]) => void journalEntryId?: string maxFiles?: number disabled?: boolean compact?: boolean } let uploadCounter = 0 const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 MB const ACCEPTED_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'] const ACCEPTED_EXTENSIONS = '.pdf,.jpg,.jpeg,.png,.webp' const FILE_NAME_TAIL_LENGTH = 16 function TruncatedFileName({ fileName }: { fileName: string }) { const characters = Array.from(fileName) if (characters.length <= FILE_NAME_TAIL_LENGTH * 2) { return ( {fileName} ) } const splitAt = characters.length - FILE_NAME_TAIL_LENGTH const start = characters.slice(0, splitAt).join('') const end = characters.slice(splitAt).join('') return ( {fileName} ) } function 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` } function isImageType(type: string): boolean { return type.startsWith('image/') } /** * Extract a Swedish error message from the structured error envelope * returned by /api/documents. Falls back to message_en or null if the * shape is unexpected. */ function extractErrorMessage(err: unknown, locale: string): string | null { if (typeof err === 'string') return err if (err && typeof err === 'object') { const e = err as { message?: unknown; message_en?: unknown; code?: unknown } if (locale === 'en' && typeof e.message_en === 'string' && e.message_en.length > 0) { return e.message_en } if (typeof e.message === 'string' && e.message.length > 0) return e.message if (typeof e.message_en === 'string' && e.message_en.length > 0) return e.message_en if (typeof e.code === 'string') return e.code } return null } export default function DocumentUploadZone({ files, onFilesChange, journalEntryId, maxFiles = 5, disabled = false, compact = false, }: DocumentUploadZoneProps) { const t = useTranslations('document_upload') const locale = useLocale() const [isDragging, setIsDragging] = useState(false) const inputRef = useRef(null) const uploadFile = useCallback(async (file: UploadedFile): Promise => { const formData = new FormData() formData.append('file', file.file) formData.append('upload_source', 'file_upload') if (journalEntryId) { formData.append('journal_entry_id', journalEntryId) } try { const res = await fetch('/api/documents', { method: 'POST', body: formData, }) // Try to parse JSON, but tolerate non-JSON responses (auth redirect HTML, 502 etc.) let result: { data?: { id?: string }; error?: unknown } = {} try { result = await res.json() } catch { console.warn('[DocumentUploadZone] Non-JSON response', { status: res.status, fileName: file.fileName, }) const reason = res.status === 401 || res.status === 403 ? t('session_expired') : t('server_status', { status: res.status }) return { ...file, status: 'error', error: reason } } if (!res.ok || result.error) { const errMessage = extractErrorMessage(result.error, locale) || t('failed_status', { status: res.status }) console.warn('[DocumentUploadZone] Upload error', { status: res.status, error: result.error, fileName: file.fileName, }) return { ...file, status: 'error', error: errMessage } } return { ...file, status: 'uploaded', id: result.data?.id } } catch (err) { console.error('[DocumentUploadZone] Upload threw', { error: err, fileName: file.fileName, }) return { ...file, status: 'error', error: t('network_error') } } }, [journalEntryId, locale, t]) const handleFiles = useCallback(async (newFiles: File[]) => { const remaining = maxFiles - files.length if (remaining <= 0) return const validFiles: UploadedFile[] = [] for (const file of newFiles.slice(0, remaining)) { if (!ACCEPTED_TYPES.includes(file.type)) { validFiles.push({ file, status: 'error', error: t('unsupported_type'), fileName: file.name, fileSize: file.size, uploadKey: `upload-${++uploadCounter}`, }) continue } if (file.size > MAX_FILE_SIZE) { validFiles.push({ file, status: 'error', error: t('too_large'), fileName: file.name, fileSize: file.size, uploadKey: `upload-${++uploadCounter}`, }) continue } validFiles.push({ file, status: 'uploading', fileName: file.name, fileSize: file.size, uploadKey: `upload-${++uploadCounter}`, }) } let currentFiles = [...files, ...validFiles] onFilesChange(currentFiles) // Upload files that passed validation for (const f of validFiles.filter((f) => f.status === 'uploading')) { const result = await uploadFile(f) currentFiles = currentFiles.map((cf) => cf.uploadKey === f.uploadKey ? result : cf ) onFilesChange([...currentFiles]) } }, [files, maxFiles, onFilesChange, t, uploadFile]) const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault() if (!disabled) setIsDragging(true) }, [disabled]) const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault() setIsDragging(false) }, []) const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault() setIsDragging(false) if (disabled) return const droppedFiles = Array.from(e.dataTransfer.files) handleFiles(droppedFiles) }, [disabled, handleFiles]) const handleInputChange = useCallback((e: React.ChangeEvent) => { const selectedFiles = e.target.files if (selectedFiles) { handleFiles(Array.from(selectedFiles)) } // Reset input so the same file can be re-selected if (inputRef.current) inputRef.current.value = '' }, [handleFiles]) const removeFile = useCallback((index: number) => { onFilesChange(files.filter((_, i) => i !== index)) }, [files, onFilesChange]) const isUploading = files.some((f) => f.status === 'uploading') return (
{/* Drop zone */}
inputRef.current?.click()} >

{compact ? t('compact_prompt') : t('prompt')}

{!compact && (

{t('format_hint')}

)}
{/* File list */} {files.length > 0 && (
{files.map((file, index) => (
{isImageType(file.file.type) ? ( ) : ( )} {formatFileSize(file.fileSize)} {file.status === 'uploading' && ( )} {file.status === 'uploaded' && ( {t('uploaded')} )} {file.status === 'error' && ( <> {t('error')} {file.error && ( {file.error} )} )}
))}
)} {isUploading && (

{t('uploading')}

)}
) }