feat: Add Chat UX improvements with notifications and @mention support
- Add ActionBar component with expandable toolbar for mobile - Add @mention functionality with autocomplete dropdown - Add browser notification system (push, sound, vibration) - Add NotificationSettings modal for user preferences - Add mention badges on room list cards - Add ReportPreview with Markdown rendering and copy/download - Add message copy functionality with hover actions - Add backend mentions field to messages with Alembic migration - Add lots field to rooms, remove templates - Optimize WebSocket database session handling - Various UX polish (animations, accessibility) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,8 @@ import {
|
||||
useUpdateMemberRole,
|
||||
useRemoveMember,
|
||||
usePermanentDeleteRoom,
|
||||
useAddLot,
|
||||
useRemoveLot,
|
||||
} from '../hooks/useRooms'
|
||||
import { useMessages } from '../hooks/useMessages'
|
||||
import { useWebSocket } from '../hooks/useWebSocket'
|
||||
@@ -16,12 +18,18 @@ import { useGenerateReport, useDownloadReport } from '../hooks/useReports'
|
||||
import { useUserSearch } from '../hooks/useUsers'
|
||||
import { useIsMobile } from '../hooks/useMediaQuery'
|
||||
import { filesService } from '../services/files'
|
||||
import { notificationService } from '../services/notification'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import { useMentionStore } from '../stores/mentionStore'
|
||||
import { useAuthStore, useIsAdmin } from '../stores/authStore'
|
||||
import { Breadcrumb } from '../components/common'
|
||||
import { MobileHeader, BottomToolbar, SlidePanel } from '../components/mobile'
|
||||
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 ReportProgress from '../components/report/ReportProgress'
|
||||
import type { SeverityLevel, RoomStatus, MemberRole, FileMetadata, ReportStatus } from '../types'
|
||||
import { formatMessageTime } from '../utils/datetime'
|
||||
import type { SeverityLevel, RoomStatus, MemberRole, FileMetadata, ReportStatus, Message } from '../types'
|
||||
|
||||
const statusColors: Record<RoomStatus, string> = {
|
||||
active: 'bg-green-100 text-green-800',
|
||||
@@ -56,6 +64,7 @@ export default function RoomDetail() {
|
||||
const { data: messagesData, isLoading: messagesLoading } = useMessages(roomId || '', { limit: 50 })
|
||||
|
||||
const { messages, connectionStatus, typingUsers, onlineUsers, setMessages, setCurrentRoom } = useChatStore()
|
||||
const { addMention, clearMentions } = useMentionStore()
|
||||
|
||||
// Handle room deleted event from WebSocket
|
||||
const handleRoomDeleted = useCallback((deletedRoomId: string) => {
|
||||
@@ -65,9 +74,32 @@ export default function RoomDetail() {
|
||||
}
|
||||
}, [roomId, navigate])
|
||||
|
||||
// Handle new message notification
|
||||
const handleNewMessage = useCallback((message: Message) => {
|
||||
// Don't notify for own messages
|
||||
if (message.sender_id === user?.username) return
|
||||
|
||||
// Check if current user is mentioned
|
||||
const isMentioned = message.content.toLowerCase().includes(`@${user?.username?.toLowerCase()}`)
|
||||
|
||||
// Track mention in store if tab is not focused
|
||||
if (isMentioned && !document.hasFocus() && roomId) {
|
||||
addMention(roomId)
|
||||
}
|
||||
|
||||
// Trigger notification
|
||||
notificationService.notifyNewMessage(
|
||||
message.sender_display_name || message.sender_id,
|
||||
message.content,
|
||||
roomId || '',
|
||||
room?.title || 'Chat Room',
|
||||
isMentioned
|
||||
)
|
||||
}, [user?.username, roomId, room?.title, addMention])
|
||||
|
||||
const { sendTextMessage, sendTyping, editMessage, deleteMessage, addReaction, removeReaction } = useWebSocket(
|
||||
roomId || null,
|
||||
{ onRoomDeleted: handleRoomDeleted }
|
||||
{ onRoomDeleted: handleRoomDeleted, onMessage: handleNewMessage }
|
||||
)
|
||||
|
||||
// Mutations
|
||||
@@ -76,6 +108,8 @@ export default function RoomDetail() {
|
||||
const updateMemberRole = useUpdateMemberRole(roomId || '')
|
||||
const removeMember = useRemoveMember(roomId || '')
|
||||
const permanentDeleteRoom = usePermanentDeleteRoom()
|
||||
const addLot = useAddLot(roomId || '')
|
||||
const removeLot = useRemoveLot(roomId || '')
|
||||
|
||||
// File hooks
|
||||
const { data: filesData, isLoading: filesLoading } = useFiles(roomId || '')
|
||||
@@ -102,6 +136,7 @@ export default function RoomDetail() {
|
||||
const [editingMessageId, setEditingMessageId] = useState<string | null>(null)
|
||||
const [editContent, setEditContent] = useState('')
|
||||
const [showEmojiPickerFor, setShowEmojiPickerFor] = useState<string | null>(null)
|
||||
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null)
|
||||
const [uploadProgress, setUploadProgress] = useState<number | null>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [previewFile, setPreviewFile] = useState<FileMetadata | null>(null)
|
||||
@@ -113,6 +148,8 @@ export default function RoomDetail() {
|
||||
const [showUserDropdown, setShowUserDropdown] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [deleteConfirmInput, setDeleteConfirmInput] = useState('')
|
||||
const [newLotInput, setNewLotInput] = useState('')
|
||||
const [showNotificationSettings, setShowNotificationSettings] = useState(false)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const typingTimeoutRef = useRef<number | null>(null)
|
||||
const searchTimeoutRef = useRef<number | null>(null)
|
||||
@@ -138,15 +175,17 @@ export default function RoomDetail() {
|
||||
}
|
||||
}, [userSearchQuery])
|
||||
|
||||
// Initialize room
|
||||
// Initialize room and clear mention badges
|
||||
useEffect(() => {
|
||||
if (roomId) {
|
||||
setCurrentRoom(roomId)
|
||||
// Clear unread mentions when entering the room
|
||||
clearMentions(roomId)
|
||||
}
|
||||
return () => {
|
||||
setCurrentRoom(null)
|
||||
}
|
||||
}, [roomId, setCurrentRoom])
|
||||
}, [roomId, setCurrentRoom, clearMentions])
|
||||
|
||||
// Load initial messages
|
||||
useEffect(() => {
|
||||
@@ -160,24 +199,6 @@ export default function RoomDetail() {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages])
|
||||
|
||||
// Handle typing indicator
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setMessageInput(e.target.value)
|
||||
|
||||
// Send typing indicator
|
||||
sendTyping(true)
|
||||
|
||||
// Clear previous timeout
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current)
|
||||
}
|
||||
|
||||
// Stop typing after 2 seconds of inactivity
|
||||
typingTimeoutRef.current = window.setTimeout(() => {
|
||||
sendTyping(false)
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
const handleSendMessage = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!messageInput.trim()) return
|
||||
@@ -210,6 +231,16 @@ export default function RoomDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyMessage = async (messageId: string, content: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content)
|
||||
setCopiedMessageId(messageId)
|
||||
setTimeout(() => setCopiedMessageId(null), 2000)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy message:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddReaction = (messageId: string, emoji: string) => {
|
||||
addReaction(messageId, emoji)
|
||||
setShowEmojiPickerFor(null)
|
||||
@@ -253,6 +284,21 @@ export default function RoomDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
// LOT handlers
|
||||
const handleAddLot = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!newLotInput.trim()) return
|
||||
addLot.mutate(newLotInput.trim(), {
|
||||
onSuccess: () => setNewLotInput(''),
|
||||
})
|
||||
}
|
||||
|
||||
const handleRemoveLot = (lot: string) => {
|
||||
if (window.confirm(`Remove LOT "${lot}"?`)) {
|
||||
removeLot.mutate(lot)
|
||||
}
|
||||
}
|
||||
|
||||
// Permanent delete room (admin only)
|
||||
const handlePermanentDelete = () => {
|
||||
if (!roomId || !room) return
|
||||
@@ -361,21 +407,52 @@ export default function RoomDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for WebSocket report progress updates
|
||||
// Listen for WebSocket report progress updates with polling fallback
|
||||
useEffect(() => {
|
||||
// This effect sets up listening for report progress via WebSocket
|
||||
// The actual WebSocket handling should be done in the useWebSocket hook
|
||||
// For now, we'll poll the report status if a report is being generated
|
||||
if (!showReportProgress || !reportProgress.reportId) return
|
||||
if (!showReportProgress || !reportProgress.reportId || !roomId) return
|
||||
if (reportProgress.status === 'completed' || reportProgress.status === 'failed') return
|
||||
|
||||
// Status message translations
|
||||
const statusMessages: Record<string, string> = {
|
||||
pending: '準備中...',
|
||||
collecting_data: '正在收集聊天室資料...',
|
||||
generating_content: 'AI 正在分析並生成報告內容...',
|
||||
assembling_document: '正在組裝報告文件...',
|
||||
completed: '報告生成完成!',
|
||||
failed: '報告生成失敗',
|
||||
}
|
||||
|
||||
// Poll every 2 seconds for status updates (fallback for WebSocket)
|
||||
const pollInterval = setInterval(async () => {
|
||||
// The WebSocket should handle this, but we keep polling as fallback
|
||||
}, 2000)
|
||||
const pollStatus = async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${import.meta.env.VITE_API_BASE_URL || '/api'}/rooms/${roomId}/reports/${reportProgress.reportId}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('auth-storage') ? JSON.parse(localStorage.getItem('auth-storage')!).state?.token : ''}`,
|
||||
},
|
||||
}
|
||||
)
|
||||
if (response.ok) {
|
||||
const report = await response.json()
|
||||
setReportProgress((prev) => ({
|
||||
...prev,
|
||||
status: report.status,
|
||||
message: statusMessages[report.status] || prev.message,
|
||||
error: report.error_message,
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to poll report status:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Poll immediately, then every 2 seconds
|
||||
pollStatus()
|
||||
const pollInterval = setInterval(pollStatus, 2000)
|
||||
|
||||
return () => clearInterval(pollInterval)
|
||||
}, [showReportProgress, reportProgress.reportId, reportProgress.status])
|
||||
}, [showReportProgress, reportProgress.reportId, reportProgress.status, roomId])
|
||||
|
||||
if (roomLoading) {
|
||||
return (
|
||||
@@ -426,6 +503,7 @@ export default function RoomDetail() {
|
||||
onGenerateReport={handleGenerateReport}
|
||||
onStatusChange={handleStatusChange}
|
||||
onPermanentDelete={() => setShowDeleteConfirm(true)}
|
||||
onNotificationSettings={() => setShowNotificationSettings(true)}
|
||||
/>
|
||||
) : (
|
||||
<header className="bg-white shadow-sm flex-shrink-0">
|
||||
@@ -453,6 +531,71 @@ export default function RoomDetail() {
|
||||
</span>
|
||||
{room.location && <span className="text-gray-500">{room.location}</span>}
|
||||
</div>
|
||||
{/* LOT Display */}
|
||||
{room.lots && room.lots.length > 0 && (
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs text-gray-500">LOT:</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{room.lots.map((lot) => (
|
||||
<span
|
||||
key={lot}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs"
|
||||
>
|
||||
{lot}
|
||||
{permissions?.can_write && (
|
||||
<button
|
||||
onClick={() => handleRemoveLot(lot)}
|
||||
className="hover:text-purple-900"
|
||||
title="Remove LOT"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{permissions?.can_write && (
|
||||
<form onSubmit={handleAddLot} className="flex items-center gap-1">
|
||||
<input
|
||||
type="text"
|
||||
value={newLotInput}
|
||||
onChange={(e) => setNewLotInput(e.target.value)}
|
||||
placeholder="Add LOT"
|
||||
className="w-24 px-2 py-0.5 text-xs border border-gray-300 rounded focus:ring-1 focus:ring-purple-500 focus:border-purple-500 outline-none"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newLotInput.trim() || addLot.isPending}
|
||||
className="px-2 py-0.5 text-xs bg-purple-600 text-white rounded hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Show add LOT form when no LOTs exist */}
|
||||
{(!room.lots || room.lots.length === 0) && permissions?.can_write && (
|
||||
<form onSubmit={handleAddLot} className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs text-gray-500">LOT:</span>
|
||||
<input
|
||||
type="text"
|
||||
value={newLotInput}
|
||||
onChange={(e) => setNewLotInput(e.target.value)}
|
||||
placeholder="Add LOT batch number"
|
||||
className="w-32 px-2 py-0.5 text-xs border border-gray-300 rounded focus:ring-1 focus:ring-purple-500 focus:border-purple-500 outline-none"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newLotInput.trim() || addLot.isPending}
|
||||
className="px-2 py-0.5 text-xs bg-purple-600 text-white rounded hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -472,21 +615,15 @@ export default function RoomDetail() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Generate Report Button */}
|
||||
{/* Notification Settings */}
|
||||
<button
|
||||
onClick={handleGenerateReport}
|
||||
disabled={generateReport.isPending}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-sm bg-purple-600 text-white rounded-md hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={() => setShowNotificationSettings(true)}
|
||||
className="p-1.5 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded"
|
||||
title="Notification settings"
|
||||
>
|
||||
<svg className="w-4 h-4" 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 className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
||||
</svg>
|
||||
{generateReport.isPending ? '生成中...' : '生成報告'}
|
||||
</button>
|
||||
|
||||
{/* Status Actions (Owner only) */}
|
||||
@@ -517,38 +654,6 @@ export default function RoomDetail() {
|
||||
Delete Permanently
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Files Toggle */}
|
||||
<button
|
||||
onClick={handleFilesToggle}
|
||||
className={`flex items-center gap-1 ${showFiles ? 'text-blue-600' : 'text-gray-600 hover:text-gray-800'}`}
|
||||
>
|
||||
<svg className="w-5 h-5" 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>
|
||||
<span className="text-sm">Files</span>
|
||||
</button>
|
||||
|
||||
{/* Members Toggle */}
|
||||
<button
|
||||
onClick={handleMembersToggle}
|
||||
className={`flex items-center gap-1 ${showMembers ? 'text-blue-600' : 'text-gray-600 hover:text-gray-800'}`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm">{room.member_count}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -588,7 +693,7 @@ export default function RoomDetail() {
|
||||
>
|
||||
{!isOwnMessage && (
|
||||
<div className="text-xs font-medium text-gray-500 mb-1">
|
||||
{message.sender_id}
|
||||
{message.sender_display_name || message.sender_id}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -623,7 +728,7 @@ export default function RoomDetail() {
|
||||
) : (
|
||||
<>
|
||||
<p className={isOwnMessage ? 'text-white' : 'text-gray-900'}>
|
||||
{message.content}
|
||||
{highlightMentions(message.content, user?.username)}
|
||||
</p>
|
||||
|
||||
{/* Reactions Display */}
|
||||
@@ -653,7 +758,7 @@ export default function RoomDetail() {
|
||||
isOwnMessage ? 'text-blue-200' : 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{new Date(message.created_at).toLocaleTimeString()}
|
||||
{formatMessageTime(message.created_at)}
|
||||
{message.edited_at && ' (edited)'}
|
||||
</div>
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
@@ -685,6 +790,22 @@ export default function RoomDetail() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Copy Message Button */}
|
||||
<button
|
||||
onClick={() => handleCopyMessage(message.message_id, message.content)}
|
||||
className={`p-1 ${isOwnMessage ? 'text-blue-200 hover:text-white' : 'text-gray-400 hover:text-gray-600'}`}
|
||||
title={copiedMessageId === message.message_id ? 'Copied!' : 'Copy message'}
|
||||
>
|
||||
{copiedMessageId === message.message_id ? (
|
||||
<svg className="w-3 h-3 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
{/* Edit/Delete (own messages only) */}
|
||||
{isOwnMessage && (
|
||||
<>
|
||||
@@ -727,18 +848,60 @@ export default function RoomDetail() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message Input */}
|
||||
{/* Action Bar - Above message input */}
|
||||
<ActionBar
|
||||
showFiles={showFiles}
|
||||
showMembers={showMembers}
|
||||
memberCount={room.member_count || 0}
|
||||
onFilesToggle={handleFilesToggle}
|
||||
onMembersToggle={handleMembersToggle}
|
||||
canWrite={permissions?.can_write || false}
|
||||
canManageMembers={permissions?.can_manage_members || false}
|
||||
isGeneratingReport={generateReport.isPending}
|
||||
uploadProgress={uploadProgress}
|
||||
onFileSelect={handleFileUpload}
|
||||
onGenerateReport={handleGenerateReport}
|
||||
onAddMemberClick={() => setShowAddMember(true)}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
|
||||
{/* Message Input with @Mention Support */}
|
||||
{permissions?.can_write && (
|
||||
<form onSubmit={handleSendMessage} className={`p-4 bg-white border-t ${isMobile ? 'pb-2' : ''}`}>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
<MentionInput
|
||||
value={messageInput}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Type a message..."
|
||||
className={`flex-1 px-4 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none ${
|
||||
isMobile ? 'py-3 text-base' : 'py-2'
|
||||
}`}
|
||||
onChange={(value) => {
|
||||
setMessageInput(value)
|
||||
// Trigger typing indicator
|
||||
sendTyping(true)
|
||||
// Clear previous timeout
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current)
|
||||
}
|
||||
// Stop typing after 2 seconds of inactivity
|
||||
typingTimeoutRef.current = window.setTimeout(() => {
|
||||
sendTyping(false)
|
||||
}, 2000)
|
||||
}}
|
||||
onSubmit={() => {
|
||||
if (messageInput.trim()) {
|
||||
sendTextMessage(messageInput.trim())
|
||||
setMessageInput('')
|
||||
// Clear typing timeout
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current)
|
||||
}
|
||||
sendTyping(false)
|
||||
}
|
||||
}}
|
||||
members={(room.members || []).map((m) => ({
|
||||
user_id: m.user_id,
|
||||
display_name: m.user_id, // TODO: Add display_name to RoomMember API
|
||||
role: m.role,
|
||||
}))}
|
||||
placeholder="Type a message... (@ to mention)"
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
@@ -752,17 +915,6 @@ export default function RoomDetail() {
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Bottom Toolbar - Mobile Only */}
|
||||
{isMobile && (
|
||||
<BottomToolbar
|
||||
showFiles={showFiles}
|
||||
showMembers={showMembers}
|
||||
memberCount={room.member_count || 0}
|
||||
onFilesToggle={handleFilesToggle}
|
||||
onMembersToggle={handleMembersToggle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Members Sidebar - Desktop Only */}
|
||||
@@ -977,11 +1129,11 @@ export default function RoomDetail() {
|
||||
<div className="text-center py-4">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"></div>
|
||||
</div>
|
||||
) : filesData?.files.length === 0 ? (
|
||||
) : filesData?.files?.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 text-center py-4">No files uploaded yet</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filesData?.files.map((file) => (
|
||||
{filesData?.files?.map((file) => (
|
||||
<div
|
||||
key={file.file_id}
|
||||
className="flex items-center gap-2 p-2 hover:bg-gray-50 rounded"
|
||||
@@ -1253,11 +1405,11 @@ export default function RoomDetail() {
|
||||
<div className="text-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
||||
</div>
|
||||
) : filesData?.files.length === 0 ? (
|
||||
) : filesData?.files?.length === 0 ? (
|
||||
<p className="text-base text-gray-500 text-center py-8">No files uploaded yet</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filesData?.files.map((file) => (
|
||||
{filesData?.files?.map((file) => (
|
||||
<div
|
||||
key={file.file_id}
|
||||
className="flex items-center gap-3 p-3 hover:bg-gray-50 rounded-lg"
|
||||
@@ -1369,6 +1521,8 @@ export default function RoomDetail() {
|
||||
message={reportProgress.message}
|
||||
error={reportProgress.error}
|
||||
reportId={reportProgress.reportId}
|
||||
roomId={roomId}
|
||||
reportTitle={room?.title}
|
||||
onDownload={handleDownloadReport}
|
||||
/>
|
||||
|
||||
@@ -1439,6 +1593,12 @@ export default function RoomDetail() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notification Settings Modal */}
|
||||
<NotificationSettings
|
||||
isOpen={showNotificationSettings}
|
||||
onClose={() => setShowNotificationSettings(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router'
|
||||
import { useRooms, useCreateRoom, useRoomTemplates, useJoinRoom } from '../hooks/useRooms'
|
||||
import { useRooms, useCreateRoom, useJoinRoom } from '../hooks/useRooms'
|
||||
import { useAuthStore, useIsAdmin } from '../stores/authStore'
|
||||
import { useMentionStore } from '../stores/mentionStore'
|
||||
import { useIsMobile } from '../hooks/useMediaQuery'
|
||||
import { Breadcrumb } from '../components/common'
|
||||
import { formatRelativeTimeGMT8 } from '../utils/datetime'
|
||||
import type { RoomStatus, IncidentType, SeverityLevel, CreateRoomRequest, Room } from '../types'
|
||||
|
||||
const statusColors: Record<RoomStatus, string> = {
|
||||
@@ -42,6 +44,7 @@ export default function RoomList() {
|
||||
const [myRoomsOnly, setMyRoomsOnly] = useState(false)
|
||||
|
||||
const joinRoom = useJoinRoom()
|
||||
const unreadMentions = useMentionStore((state) => state.unreadMentions)
|
||||
|
||||
// Reset page when filters change
|
||||
const handleStatusChange = (status: RoomStatus | '') => {
|
||||
@@ -182,7 +185,7 @@ export default function RoomList() {
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-500">Failed to load rooms</p>
|
||||
</div>
|
||||
) : data?.rooms.length === 0 ? (
|
||||
) : !data?.rooms?.length ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500">No rooms found</p>
|
||||
<button
|
||||
@@ -195,13 +198,14 @@ export default function RoomList() {
|
||||
) : (
|
||||
<>
|
||||
<div className={`grid gap-4 ${isMobile ? 'grid-cols-1' : 'md:grid-cols-2 lg:grid-cols-3'}`}>
|
||||
{data?.rooms.map((room) => (
|
||||
{data?.rooms?.map((room) => (
|
||||
<RoomCard
|
||||
key={room.room_id}
|
||||
room={room}
|
||||
onJoin={handleJoinRoom}
|
||||
isJoining={joinRoom.isPending}
|
||||
isMobile={isMobile}
|
||||
mentionCount={unreadMentions[room.room_id] || 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -249,11 +253,13 @@ function RoomCard({
|
||||
onJoin,
|
||||
isJoining,
|
||||
isMobile,
|
||||
mentionCount,
|
||||
}: {
|
||||
room: Room
|
||||
onJoin: (e: React.MouseEvent, roomId: string) => void
|
||||
isJoining: boolean
|
||||
isMobile: boolean
|
||||
mentionCount: number
|
||||
}) {
|
||||
const isMember = room.is_member || room.current_user_role !== null
|
||||
|
||||
@@ -261,10 +267,17 @@ function RoomCard({
|
||||
<Link
|
||||
to={isMember ? `/rooms/${room.room_id}` : '#'}
|
||||
onClick={!isMember ? (e) => e.preventDefault() : undefined}
|
||||
className={`bg-white rounded-lg shadow-sm border border-gray-200 hover:shadow-md transition-shadow block ${
|
||||
className={`bg-white rounded-lg shadow-sm border border-gray-200 hover:shadow-md transition-shadow block relative ${
|
||||
isMobile ? 'p-4' : 'p-4'
|
||||
}`}
|
||||
>
|
||||
{/* Mention Badge */}
|
||||
{mentionCount > 0 && (
|
||||
<div className="absolute -top-2 -right-2 bg-red-500 text-white text-xs font-bold rounded-full min-w-[20px] h-5 flex items-center justify-center px-1.5 shadow-sm">
|
||||
{mentionCount > 99 ? '99+' : mentionCount}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Room Header */}
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<h3 className={`font-semibold text-gray-900 truncate flex-1 ${isMobile ? 'text-base' : ''}`}>
|
||||
@@ -326,7 +339,7 @@ function RoomCard({
|
||||
</button>
|
||||
) : (
|
||||
<span className={`text-gray-400 ${isMobile ? 'text-sm' : 'text-xs'}`}>
|
||||
{new Date(room.last_activity_at).toLocaleDateString()}
|
||||
{formatRelativeTimeGMT8(room.last_activity_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -343,18 +356,39 @@ function CreateRoomModal({ onClose }: { onClose: () => void }) {
|
||||
const [location, setLocation] = useState('')
|
||||
|
||||
const createRoom = useCreateRoom()
|
||||
// Templates loaded for future use (template selection feature)
|
||||
useRoomTemplates()
|
||||
const [lots, setLots] = useState<string[]>([''])
|
||||
|
||||
const handleAddLot = () => {
|
||||
setLots([...lots, ''])
|
||||
}
|
||||
|
||||
const handleRemoveLot = (index: number) => {
|
||||
if (lots.length > 1) {
|
||||
setLots(lots.filter((_, i) => i !== index))
|
||||
} else {
|
||||
setLots([''])
|
||||
}
|
||||
}
|
||||
|
||||
const handleLotChange = (index: number, value: string) => {
|
||||
const newLots = [...lots]
|
||||
newLots[index] = value
|
||||
setLots(newLots)
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
// Filter out empty LOT values
|
||||
const validLots = lots.filter(lot => lot.trim() !== '')
|
||||
|
||||
const data: CreateRoomRequest = {
|
||||
title,
|
||||
incident_type: incidentType,
|
||||
severity,
|
||||
description: description || undefined,
|
||||
location: location || undefined,
|
||||
lots: validLots.length > 0 ? validLots : undefined,
|
||||
}
|
||||
|
||||
createRoom.mutate(data, {
|
||||
@@ -447,6 +481,46 @@ function CreateRoomModal({ onClose }: { onClose: () => void }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* LOT Batch Numbers */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
LOT Batch Numbers
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{lots.map((lot, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={lot}
|
||||
onChange={(e) => handleLotChange(index, e.target.value)}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
|
||||
placeholder="e.g., LOT-2024-001"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveLot(index)}
|
||||
className="px-3 py-2 text-red-600 hover:bg-red-50 rounded-lg"
|
||||
title="Remove LOT"
|
||||
>
|
||||
<svg className="w-5 h-5" 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>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddLot}
|
||||
className="flex items-center gap-1 text-blue-600 hover:text-blue-700 text-sm"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add LOT
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{createRoom.isError && (
|
||||
<div className="bg-red-50 text-red-600 px-3 py-2 rounded-lg text-sm">
|
||||
|
||||
Reference in New Issue
Block a user