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:
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user