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

@@ -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 ? (