feat: Improve file display, timezone handling, and LOT management

Changes:
- Fix datetime serialization with UTC 'Z' suffix for correct timezone display
- Add PDF upload support with extension fallback for MIME detection
- Fix LOT add/remove by creating new list for SQLAlchemy JSON change detection
- Add file message components (FileMessage, ImageLightbox, UploadPreview)
- Add multi-file upload support with progress tracking
- Link uploaded files to chat messages via message_id
- Include file attachments in AI report generation
- Update specs for file-storage, realtime-messaging, and ai-report-generation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
egg
2025-12-08 12:39:15 +08:00
parent 599802b818
commit 44822a561a
36 changed files with 2252 additions and 156 deletions

View File

@@ -12,6 +12,7 @@ interface ActionBarProps {
canManageMembers: boolean
isGeneratingReport: boolean
uploadProgress: number | null
uploadInfo?: { current: number; total: number } | null // For multi-file upload
onFileSelect: (files: FileList | null) => void
onGenerateReport: () => void
onAddMemberClick: () => void
@@ -29,6 +30,7 @@ export function ActionBar({
canManageMembers,
isGeneratingReport,
uploadProgress,
uploadInfo,
onFileSelect,
onGenerateReport,
onAddMemberClick,
@@ -54,13 +56,18 @@ export function ActionBar({
return (
<div className="bg-white border-t border-gray-200">
{/* Hidden file input */}
{/* Hidden file input - supports multiple file selection */}
<input
type="file"
ref={fileInputRef}
onChange={(e) => onFileSelect(e.target.files)}
onChange={(e) => {
onFileSelect(e.target.files)
// Reset input so same files can be selected again
e.target.value = ''
}}
className="hidden"
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt,.log"
multiple
/>
{/* Action bar content */}
@@ -121,12 +128,16 @@ export function ActionBar({
onClick={handleFileClick}
disabled={uploadProgress !== null}
className={buttonClass}
title="Upload file"
title="Upload files (multiple supported)"
>
{uploadProgress !== null ? (
<div className="flex items-center gap-1.5">
<div className="w-4 h-4 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
<span>{uploadProgress}%</span>
<span>
{uploadInfo && uploadInfo.total > 1
? `${uploadInfo.current}/${uploadInfo.total} (${uploadProgress}%)`
: `${uploadProgress}%`}
</span>
</div>
) : (
<>
@@ -217,7 +228,11 @@ export function ActionBar({
{uploadProgress !== null ? (
<>
<div className="w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
<span className="text-xs">{uploadProgress}%</span>
<span className="text-xs">
{uploadInfo && uploadInfo.total > 1
? `${uploadInfo.current}/${uploadInfo.total}`
: `${uploadProgress}%`}
</span>
</>
) : (
<>

View File

@@ -0,0 +1,288 @@
import { useState } from 'react'
import type { Message } from '../../types'
import { formatMessageTime } from '../../utils/datetime'
import { filesService } from '../../services/files'
import { ImageLightbox } from './ImageLightbox'
// File type icon mapping with colors
const FILE_ICONS: Record<string, { icon: React.ReactNode; color: string }> = {
'application/pdf': {
icon: (
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm-1 2l5 5h-5V4zM9 15h6v1H9v-1zm0-2h6v1H9v-1zm0-2h6v1H9v-1z"/>
</svg>
),
color: 'text-red-500 bg-red-100',
},
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': {
icon: (
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm-1 2l5 5h-5V4zM8 17h3v-1H8v1zm0-2h3v-1H8v1zm0-2h3v-1H8v1zm5 4h3v-1h-3v1zm0-2h3v-1h-3v1zm0-2h3v-1h-3v1z"/>
</svg>
),
color: 'text-green-600 bg-green-100',
},
'application/vnd.ms-excel': {
icon: (
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm-1 2l5 5h-5V4zM8 17h3v-1H8v1zm0-2h3v-1H8v1zm0-2h3v-1H8v1zm5 4h3v-1h-3v1zm0-2h3v-1h-3v1zm0-2h3v-1h-3v1z"/>
</svg>
),
color: 'text-green-600 bg-green-100',
},
'text/plain': {
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
),
color: 'text-gray-500 bg-gray-100',
},
'text/csv': {
icon: (
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm-1 2l5 5h-5V4zM7 13h2v2H7v-2zm0 3h2v2H7v-2zm3-3h2v2h-2v-2zm0 3h2v2h-2v-2zm3-3h2v2h-2v-2zm0 3h2v2h-2v-2z"/>
</svg>
),
color: 'text-green-500 bg-green-100',
},
'application/x-log': {
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
),
color: 'text-orange-500 bg-orange-100',
},
default: {
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
),
color: 'text-gray-400 bg-gray-100',
},
}
function getFileIcon(mimeType: string, filename: string): { icon: React.ReactNode; color: string } {
// Check mime type first
if (FILE_ICONS[mimeType]) {
return FILE_ICONS[mimeType]
}
// Check file extension for .log files
const ext = filename.split('.').pop()?.toLowerCase()
if (ext === 'log') {
return FILE_ICONS['application/x-log']
}
return FILE_ICONS.default
}
interface FileMessageProps {
message: Message
isOwnMessage: boolean
onDownload: (url: string, filename: string) => void
}
interface FileMetadataFromMessage {
file_id: string
file_url: string
filename: string
file_type: string
mime_type?: string
file_size?: number
thumbnail_url?: string
}
export function FileMessage({ message, isOwnMessage, onDownload }: FileMessageProps) {
const [showLightbox, setShowLightbox] = useState(false)
const [imageLoaded, setImageLoaded] = useState(false)
const [imageError, setImageError] = useState(false)
// Debug: Log the message data
console.log('[FileMessage] Rendering message:', {
message_id: message.message_id,
message_type: message.message_type,
metadata: message.metadata,
content: message.content,
})
// Extract file metadata from message.metadata
const metadata = message.metadata as FileMetadataFromMessage | undefined
if (!metadata?.file_url) {
console.warn('[FileMessage] No file_url in metadata, returning null. Metadata:', metadata)
return null
}
const {
file_url,
filename,
file_type,
mime_type = '',
file_size = 0,
thumbnail_url,
} = metadata
const isImage = file_type === 'image' || mime_type.startsWith('image/')
const fileIcon = getFileIcon(mime_type, filename)
const displayUrl = thumbnail_url || file_url
// Extract caption from message content (if not the default [Image] or [File] prefix)
const hasCaption = message.content &&
!message.content.startsWith('[Image]') &&
!message.content.startsWith('[File]')
const caption = hasCaption ? message.content : null
const handleDownloadClick = (e: React.MouseEvent) => {
e.stopPropagation()
onDownload(file_url, filename)
}
if (isImage) {
return (
<>
<div
className={`max-w-[70%] rounded-lg overflow-hidden ${
isOwnMessage ? 'bg-blue-600' : 'bg-white shadow-sm'
}`}
>
{/* Sender name */}
{!isOwnMessage && (
<div className="px-3 pt-2 text-xs font-medium text-gray-500">
{message.sender_display_name || message.sender_id}
</div>
)}
{/* Image thumbnail */}
<div
className="relative cursor-pointer group"
onClick={() => setShowLightbox(true)}
>
{!imageLoaded && !imageError && (
<div className="w-48 h-48 flex items-center justify-center bg-gray-100">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
)}
{imageError ? (
<div className="w-48 h-32 flex items-center justify-center bg-gray-100 text-gray-400">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
) : (
<img
src={displayUrl}
alt={filename}
className={`max-w-[300px] max-h-[300px] object-contain ${!imageLoaded ? 'hidden' : ''}`}
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
/>
)}
{/* Hover overlay */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center opacity-0 group-hover:opacity-100">
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7" />
</svg>
</div>
</div>
{/* Caption and metadata */}
<div className="px-3 py-2">
{caption && (
<p className={`text-sm mb-1 ${isOwnMessage ? 'text-white' : 'text-gray-900'}`}>
{caption}
</p>
)}
<div className="flex items-center justify-between">
<span className={`text-xs ${isOwnMessage ? 'text-blue-200' : 'text-gray-400'}`}>
{formatMessageTime(message.created_at)}
</span>
<button
onClick={handleDownloadClick}
className={`p-1 rounded hover:bg-black/10 ${
isOwnMessage ? 'text-blue-200 hover:text-white' : 'text-gray-400 hover:text-gray-600'
}`}
title="Download"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</button>
</div>
</div>
</div>
{/* Lightbox */}
{showLightbox && (
<ImageLightbox
src={file_url}
alt={filename}
filename={filename}
onClose={() => setShowLightbox(false)}
onDownload={() => onDownload(file_url, filename)}
/>
)}
</>
)
}
// Non-image file display
return (
<div
className={`max-w-[70%] rounded-lg px-4 py-3 ${
isOwnMessage ? 'bg-blue-600 text-white' : 'bg-white shadow-sm'
}`}
>
{/* Sender name */}
{!isOwnMessage && (
<div className="text-xs font-medium text-gray-500 mb-2">
{message.sender_display_name || message.sender_id}
</div>
)}
{/* File info */}
<div className="flex items-center gap-3">
{/* File icon */}
<div className={`w-12 h-12 rounded-lg flex items-center justify-center ${fileIcon.color}`}>
{fileIcon.icon}
</div>
{/* File details */}
<div className="flex-1 min-w-0">
<p className={`text-sm font-medium truncate ${isOwnMessage ? 'text-white' : 'text-gray-900'}`}>
{filename}
</p>
<p className={`text-xs ${isOwnMessage ? 'text-blue-200' : 'text-gray-500'}`}>
{file_size ? filesService.formatFileSize(file_size) : 'File'}
</p>
</div>
{/* Download button */}
<button
onClick={handleDownloadClick}
className={`p-2 rounded-full hover:bg-black/10 ${
isOwnMessage ? 'text-blue-200 hover:text-white' : 'text-gray-400 hover:text-blue-600'
}`}
title="Download"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</button>
</div>
{/* Caption if any */}
{caption && (
<p className={`text-sm mt-2 ${isOwnMessage ? 'text-white' : 'text-gray-900'}`}>
{caption}
</p>
)}
{/* Timestamp */}
<div className={`text-xs mt-2 ${isOwnMessage ? 'text-blue-200' : 'text-gray-400'}`}>
{formatMessageTime(message.created_at)}
</div>
</div>
)
}

View File

@@ -0,0 +1,123 @@
import { useEffect, useState, useCallback } from 'react'
interface ImageLightboxProps {
src: string
alt: string
filename: string
onClose: () => void
onDownload: () => void
}
export function ImageLightbox({ src, alt, filename, onClose, onDownload }: ImageLightboxProps) {
const [isLoading, setIsLoading] = useState(true)
const [hasError, setHasError] = useState(false)
// Handle keyboard events
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose()
}
}, [onClose])
useEffect(() => {
// Add event listener
document.addEventListener('keydown', handleKeyDown)
// Prevent body scroll when lightbox is open
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.body.style.overflow = ''
}
}, [handleKeyDown])
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose()
}
}
const handleDownloadClick = (e: React.MouseEvent) => {
e.stopPropagation()
onDownload()
}
return (
<div
className="fixed inset-0 bg-black/90 flex items-center justify-center z-50"
onClick={handleBackdropClick}
role="dialog"
aria-modal="true"
aria-label={`Image preview: ${filename}`}
>
{/* Close button */}
<button
onClick={onClose}
className="absolute top-4 right-4 text-white/80 hover:text-white p-2 rounded-full hover:bg-white/10 transition-colors"
aria-label="Close preview (Escape)"
>
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/* Image container */}
<div className="relative max-w-[90vw] max-h-[85vh] flex flex-col items-center">
{/* Loading state */}
{isLoading && !hasError && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-white/30 border-t-white"></div>
</div>
)}
{/* Error state */}
{hasError && (
<div className="flex flex-col items-center justify-center text-white/70 py-16">
<svg className="w-16 h-16 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<p className="text-lg">Failed to load image</p>
<p className="text-sm text-white/50 mt-1">{filename}</p>
</div>
)}
{/* Image */}
<img
src={src}
alt={alt}
className={`max-w-full max-h-[85vh] object-contain ${isLoading || hasError ? 'invisible' : ''}`}
onLoad={() => setIsLoading(false)}
onError={() => {
setIsLoading(false)
setHasError(true)
}}
onClick={(e) => e.stopPropagation()}
/>
{/* Bottom toolbar */}
<div className="absolute -bottom-12 left-0 right-0 flex items-center justify-center gap-6">
{/* Filename */}
<span className="text-white/80 text-sm truncate max-w-[50vw]">
{filename}
</span>
{/* Download button */}
<button
onClick={handleDownloadClick}
className="flex items-center gap-2 text-white/80 hover:text-white text-sm px-3 py-1.5 rounded-full hover:bg-white/10 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Download
</button>
</div>
</div>
{/* Keyboard hint */}
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 text-white/40 text-xs">
Press <kbd className="px-1.5 py-0.5 bg-white/10 rounded text-white/60">ESC</kbd> to close
</div>
</div>
)
}

View File

@@ -0,0 +1,207 @@
import { useState, useEffect, useRef } from 'react'
import { filesService } from '../../services/files'
// File type icon mapping with colors
const FILE_ICONS: Record<string, { icon: React.ReactNode; bgColor: string; textColor: string }> = {
'application/pdf': {
icon: (
<svg className="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm-1 2l5 5h-5V4zM9 15h6v1H9v-1zm0-2h6v1H9v-1zm0-2h6v1H9v-1z"/>
</svg>
),
bgColor: 'bg-red-100',
textColor: 'text-red-500',
},
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': {
icon: (
<svg className="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm-1 2l5 5h-5V4z"/>
</svg>
),
bgColor: 'bg-green-100',
textColor: 'text-green-600',
},
'application/vnd.ms-excel': {
icon: (
<svg className="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm-1 2l5 5h-5V4z"/>
</svg>
),
bgColor: 'bg-green-100',
textColor: 'text-green-600',
},
'text/plain': {
icon: (
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
),
bgColor: 'bg-gray-100',
textColor: 'text-gray-500',
},
default: {
icon: (
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
),
bgColor: 'bg-gray-100',
textColor: 'text-gray-400',
},
}
function getFileIcon(mimeType: string, filename: string) {
if (FILE_ICONS[mimeType]) {
return FILE_ICONS[mimeType]
}
const ext = filename.split('.').pop()?.toLowerCase()
if (ext === 'log') {
return { ...FILE_ICONS.default, bgColor: 'bg-orange-100', textColor: 'text-orange-500' }
}
return FILE_ICONS.default
}
interface UploadPreviewProps {
file: File
uploadProgress: number | null
onCancel: () => void
onSend: (description?: string) => void
isMobile?: boolean
}
export function UploadPreview({ file, uploadProgress, onCancel, onSend, isMobile = false }: UploadPreviewProps) {
const [description, setDescription] = useState('')
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const isImage = file.type.startsWith('image/')
// Generate preview for images
useEffect(() => {
if (isImage) {
const url = URL.createObjectURL(file)
setPreviewUrl(url)
return () => URL.revokeObjectURL(url)
}
}, [file, isImage])
// Auto-focus the input
useEffect(() => {
if (inputRef.current && !isMobile) {
inputRef.current.focus()
}
}, [isMobile])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
onSend(description.trim() || undefined)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
onSend(description.trim() || undefined)
}
if (e.key === 'Escape') {
onCancel()
}
}
const isUploading = uploadProgress !== null
const fileIcon = getFileIcon(file.type, file.name)
return (
<div className={`bg-gray-50 border-t border-gray-200 ${isMobile ? 'p-3' : 'p-4'}`}>
<div className="flex items-start gap-3">
{/* Preview */}
<div className="flex-shrink-0">
{isImage && previewUrl ? (
<div className="relative w-20 h-20 rounded-lg overflow-hidden bg-gray-200">
<img
src={previewUrl}
alt={file.name}
className="w-full h-full object-cover"
/>
</div>
) : (
<div className={`w-20 h-20 rounded-lg flex items-center justify-center ${fileIcon.bgColor}`}>
<div className={fileIcon.textColor}>{fileIcon.icon}</div>
</div>
)}
</div>
{/* File info and input */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<p className="text-sm font-medium text-gray-900 truncate pr-2">{file.name}</p>
{!isUploading && (
<button
onClick={onCancel}
className="flex-shrink-0 p-1 text-gray-400 hover:text-gray-600 rounded"
title="Cancel"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
<p className="text-xs text-gray-500 mb-2">{filesService.formatFileSize(file.size)}</p>
{/* Description input */}
{!isUploading && (
<form onSubmit={handleSubmit} className="flex gap-2">
<input
ref={inputRef}
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Add a caption (optional)..."
className={`flex-1 px-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none ${
isMobile ? 'py-2.5 text-base' : 'py-1.5 text-sm'
}`}
maxLength={500}
/>
<button
type="submit"
className={`flex-shrink-0 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center gap-1 ${
isMobile ? 'px-4 py-2.5' : 'px-3 py-1.5'
}`}
>
<svg className={`${isMobile ? 'w-5 h-5' : 'w-4 h-4'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
{!isMobile && <span className="text-sm">Send</span>}
</button>
</form>
)}
{/* Upload progress */}
{isUploading && (
<div>
<div className="flex items-center justify-between text-sm text-gray-600 mb-1">
<span>Uploading...</span>
<span>{uploadProgress}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
/>
</div>
</div>
)}
</div>
</div>
{/* Keyboard hint (desktop only) */}
{!isMobile && !isUploading && (
<div className="mt-2 text-xs text-gray-400 text-right">
Press <kbd className="px-1 py-0.5 bg-gray-200 rounded text-gray-500">Enter</kbd> to send,{' '}
<kbd className="px-1 py-0.5 bg-gray-200 rounded text-gray-500">Esc</kbd> to cancel
</div>
)}
</div>
)
}

View File

@@ -134,10 +134,18 @@ export function useWebSocket(roomId: string | null, options?: UseWebSocketOption
(data: unknown) => {
const msg = data as { type: string }
// Debug: Log all incoming WebSocket messages
console.log('[WebSocket] Received message:', msg.type, data)
switch (msg.type) {
case 'message':
case 'edit_message': {
const messageBroadcast = data as MessageBroadcast
console.log('[WebSocket] Processing message broadcast:', {
message_id: messageBroadcast.message_id,
message_type: messageBroadcast.message_type,
metadata: messageBroadcast.metadata,
})
const message: Message = {
message_id: messageBroadcast.message_id,
room_id: messageBroadcast.room_id,
@@ -160,7 +168,8 @@ export function useWebSocket(roomId: string | null, options?: UseWebSocketOption
break
}
case 'delete_message': {
case 'delete_message':
case 'message_deleted': {
const deleteMsg = data as { message_id: string }
removeMessage(deleteMsg.message_id)
break
@@ -192,6 +201,10 @@ export function useWebSocket(roomId: string | null, options?: UseWebSocketOption
case 'file_deleted': {
const fileData = data as FileDeletedBroadcast
// Also remove the associated message from chat if it exists
if (fileData.message_id) {
removeMessage(fileData.message_id)
}
options?.onFileDeleted?.(fileData)
break
}

View File

@@ -27,6 +27,8 @@ import { MobileHeader, SlidePanel } from '../components/mobile'
import { ActionBar } from '../components/chat/ActionBar'
import { MentionInput, highlightMentions } from '../components/chat/MentionInput'
import { NotificationSettings } from '../components/chat/NotificationSettings'
import { FileMessage } from '../components/chat/FileMessage'
import { UploadPreview } from '../components/chat/UploadPreview'
import ReportProgress from '../components/report/ReportProgress'
import { formatMessageTime } from '../utils/datetime'
import type { SeverityLevel, RoomStatus, MemberRole, FileMetadata, ReportStatus, Message } from '../types'
@@ -138,8 +140,11 @@ export default function RoomDetail() {
const [showEmojiPickerFor, setShowEmojiPickerFor] = useState<string | null>(null)
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null)
const [uploadProgress, setUploadProgress] = useState<number | null>(null)
const [uploadInfo, setUploadInfo] = useState<{ current: number; total: number } | null>(null)
const [isDragging, setIsDragging] = useState(false)
const [previewFile, setPreviewFile] = useState<FileMetadata | null>(null)
const [pendingUploadFile, setPendingUploadFile] = useState<File | null>(null)
const [pendingUploadFiles, setPendingUploadFiles] = useState<File[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
const [newMemberUsername, setNewMemberUsername] = useState('')
const [newMemberRole, setNewMemberRole] = useState<MemberRole>('viewer')
@@ -310,25 +315,68 @@ export default function RoomDetail() {
})
}
// File handlers
const handleFileUpload = useCallback(
(files: FileList | null) => {
if (!files || files.length === 0) return
// File handlers - Show preview before upload (single file) or upload immediately (multiple files)
const handleFileSelect = useCallback(
async (files: FileList | null) => {
if (!files || files.length === 0 || !roomId) return
if (files.length === 1) {
// Single file: show preview before upload
setPendingUploadFile(files[0])
} else {
// Multiple files: upload immediately without preview
const fileArray = Array.from(files)
setPendingUploadFiles(fileArray)
setUploadProgress(0)
setUploadInfo({ current: 1, total: fileArray.length })
try {
const result = await filesService.uploadFiles(
roomId,
fileArray,
(progress, current, total) => {
setUploadProgress(progress)
setUploadInfo({ current, total })
}
)
if (result.failed.length > 0) {
// Show alert for failed uploads
const failedNames = result.failed.map(f => f.file).map(f => f.name).join(', ')
alert(`Failed to upload: ${failedNames}`)
}
} catch (error) {
console.error('Multi-file upload failed:', error)
} finally {
setUploadProgress(null)
setUploadInfo(null)
setPendingUploadFiles([])
}
}
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
},
[roomId]
)
const handleUploadWithDescription = useCallback(
(description?: string) => {
if (!pendingUploadFile) return
const file = files[0]
setUploadProgress(0)
uploadFile.mutate(
{
file,
file: pendingUploadFile,
description,
onProgress: (progress) => setUploadProgress(progress),
},
{
onSuccess: () => {
setUploadProgress(null)
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
setPendingUploadFile(null)
},
onError: () => {
setUploadProgress(null)
@@ -336,16 +384,21 @@ export default function RoomDetail() {
}
)
},
[uploadFile]
[pendingUploadFile, uploadFile]
)
const handleCancelUpload = useCallback(() => {
setPendingUploadFile(null)
setUploadProgress(null)
}, [])
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
handleFileUpload(e.dataTransfer.files)
handleFileSelect(e.dataTransfer.files)
},
[handleFileUpload]
[handleFileSelect]
)
const handleDragOver = useCallback((e: React.DragEvent) => {
@@ -678,6 +731,32 @@ export default function RoomDetail() {
messages.map((message) => {
const isOwnMessage = message.sender_id === user?.username
const isEditing = editingMessageId === message.message_id
const isFileMessage = message.message_type === 'image_ref' || message.message_type === 'file_ref'
// Handle file/image messages with FileMessage component
if (isFileMessage) {
return (
<div
key={message.message_id}
className={`flex ${isOwnMessage ? 'justify-end' : 'justify-start'} group`}
>
<FileMessage
message={message}
isOwnMessage={isOwnMessage}
onDownload={(url, filename) => {
// Open in new tab for download
const link = document.createElement('a')
link.href = url
link.download = filename
link.target = '_blank'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}}
/>
</div>
)
}
return (
<div
@@ -858,15 +937,27 @@ export default function RoomDetail() {
canWrite={permissions?.can_write || false}
canManageMembers={permissions?.can_manage_members || false}
isGeneratingReport={generateReport.isPending}
uploadProgress={uploadProgress}
onFileSelect={handleFileUpload}
uploadProgress={pendingUploadFile || pendingUploadFiles.length > 0 ? uploadProgress : null}
uploadInfo={uploadInfo}
onFileSelect={handleFileSelect}
onGenerateReport={handleGenerateReport}
onAddMemberClick={() => setShowAddMember(true)}
isMobile={isMobile}
/>
{/* Upload Preview - Show when file selected but not yet uploaded */}
{pendingUploadFile && (
<UploadPreview
file={pendingUploadFile}
uploadProgress={uploadProgress}
onCancel={handleCancelUpload}
onSend={handleUploadWithDescription}
isMobile={isMobile}
/>
)}
{/* Message Input with @Mention Support */}
{permissions?.can_write && (
{permissions?.can_write && !pendingUploadFile && (
<form onSubmit={handleSendMessage} className={`p-4 bg-white border-t ${isMobile ? 'pb-2' : ''}`}>
<div className="flex gap-2">
<MentionInput
@@ -1091,7 +1182,7 @@ export default function RoomDetail() {
<input
type="file"
ref={fileInputRef}
onChange={(e) => handleFileUpload(e.target.files)}
onChange={(e) => handleFileSelect(e.target.files)}
className="hidden"
/>
{uploadProgress !== null ? (
@@ -1370,7 +1461,7 @@ export default function RoomDetail() {
<input
type="file"
ref={fileInputRef}
onChange={(e) => handleFileUpload(e.target.files)}
onChange={(e) => handleFileSelect(e.target.files)}
className="hidden"
/>
{uploadProgress !== null ? (

View File

@@ -46,6 +46,55 @@ export const filesService = {
return response.data
},
/**
* Upload multiple files to room sequentially
* Reports overall progress across all files
*/
async uploadFiles(
roomId: string,
files: File[],
onProgress?: (progress: number, currentFile: number, totalFiles: number) => void
): Promise<{ successful: FileUploadResponse[]; failed: { file: File; error: string }[] }> {
const successful: FileUploadResponse[] = []
const failed: { file: File; error: string }[] = []
const totalFiles = files.length
for (let i = 0; i < files.length; i++) {
const file = files[i]
const currentFile = i + 1
try {
const result = await this.uploadFile(
roomId,
file,
undefined,
(fileProgress) => {
if (onProgress) {
// Calculate overall progress: completed files + current file progress
const completedProgress = (i / totalFiles) * 100
const currentFileContribution = (fileProgress / totalFiles)
const overallProgress = Math.round(completedProgress + currentFileContribution)
onProgress(overallProgress, currentFile, totalFiles)
}
}
)
successful.push(result)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Upload failed'
failed.push({ file, error: errorMessage })
console.error(`Failed to upload ${file.name}:`, error)
}
// Report completion of this file
if (onProgress) {
const overallProgress = Math.round(((i + 1) / totalFiles) * 100)
onProgress(overallProgress, currentFile, totalFiles)
}
}
return { successful, failed }
},
/**
* List files in a room
*/

View File

@@ -127,6 +127,7 @@ export type FileType = 'image' | 'document' | 'log'
export interface FileMetadata {
file_id: string
message_id?: string | null // Associated chat message ID
room_id: string
filename: string
file_type: FileType
@@ -138,15 +139,18 @@ export interface FileMetadata {
uploader_id: string
deleted_at?: string | null
download_url?: string
thumbnail_url?: string | null // Thumbnail URL for images
}
export interface FileUploadResponse {
file_id: string
message_id?: string | null // Associated chat message ID
filename: string
file_type: FileType
file_size: number
mime_type: string
download_url: string
thumbnail_url?: string | null // Thumbnail URL for images
uploaded_at: string
uploader_id: string
}
@@ -222,6 +226,7 @@ export interface TypingBroadcast {
export interface FileUploadedBroadcast {
type: 'file_uploaded'
file_id: string
message_id?: string | null // Associated chat message ID
room_id: string
uploader_id: string
filename: string
@@ -229,12 +234,14 @@ export interface FileUploadedBroadcast {
file_size: number
mime_type: string
download_url?: string
thumbnail_url?: string | null // Thumbnail URL for images
uploaded_at: string
}
export interface FileDeletedBroadcast {
type: 'file_deleted'
file_id: string
message_id?: string | null // Associated chat message ID (also deleted)
room_id: string
deleted_by: string
deleted_at: string