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:
3
frontend/.env.production
Normal file
3
frontend/.env.production
Normal file
@@ -0,0 +1,3 @@
|
||||
# Production environment configuration
|
||||
# API Base URL - point directly to the backend server
|
||||
VITE_API_BASE_URL=http://localhost:8000/api
|
||||
1466
frontend/package-lock.json
generated
1466
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,9 @@
|
||||
"axios": "^1.13.2",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^7.9.6",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
269
frontend/src/components/chat/ActionBar.tsx
Normal file
269
frontend/src/components/chat/ActionBar.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import { useState, useRef } from 'react'
|
||||
|
||||
interface ActionBarProps {
|
||||
// Toggles
|
||||
showFiles: boolean
|
||||
showMembers: boolean
|
||||
memberCount: number
|
||||
onFilesToggle: () => void
|
||||
onMembersToggle: () => void
|
||||
// Actions
|
||||
canWrite: boolean
|
||||
canManageMembers: boolean
|
||||
isGeneratingReport: boolean
|
||||
uploadProgress: number | null
|
||||
onFileSelect: (files: FileList | null) => void
|
||||
onGenerateReport: () => void
|
||||
onAddMemberClick: () => void
|
||||
// Mobile
|
||||
isMobile?: boolean
|
||||
}
|
||||
|
||||
export function ActionBar({
|
||||
showFiles,
|
||||
showMembers,
|
||||
memberCount,
|
||||
onFilesToggle,
|
||||
onMembersToggle,
|
||||
canWrite,
|
||||
canManageMembers,
|
||||
isGeneratingReport,
|
||||
uploadProgress,
|
||||
onFileSelect,
|
||||
onGenerateReport,
|
||||
onAddMemberClick,
|
||||
isMobile = false,
|
||||
}: ActionBarProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const toggleExpand = () => setExpanded(!expanded)
|
||||
|
||||
const handleFileClick = () => {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
// Compact mode button style
|
||||
const buttonClass = isMobile
|
||||
? 'flex flex-col items-center justify-center p-2 rounded-lg text-gray-600 hover:bg-gray-100 active:bg-gray-200 touch-target'
|
||||
: 'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-gray-600 hover:bg-gray-100 text-sm'
|
||||
|
||||
const activeButtonClass = isMobile
|
||||
? 'flex flex-col items-center justify-center p-2 rounded-lg text-blue-600 bg-blue-50 touch-target'
|
||||
: 'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-blue-600 bg-blue-50 text-sm'
|
||||
|
||||
return (
|
||||
<div className="bg-white border-t border-gray-200">
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={(e) => onFileSelect(e.target.files)}
|
||||
className="hidden"
|
||||
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt,.log"
|
||||
/>
|
||||
|
||||
{/* Action bar content */}
|
||||
<div className="px-3 py-2">
|
||||
{/* Mobile: Expandable toolbar */}
|
||||
{isMobile ? (
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Left side - expand toggle */}
|
||||
<button
|
||||
onClick={toggleExpand}
|
||||
className={`p-2 rounded-lg ${expanded ? 'text-blue-600 bg-blue-50' : 'text-gray-500'}`}
|
||||
aria-expanded={expanded}
|
||||
aria-label="Toggle action toolbar"
|
||||
>
|
||||
<svg className={`w-6 h-6 transition-transform ${expanded ? 'rotate-45' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Right side - quick toggles */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Files toggle */}
|
||||
<button
|
||||
onClick={onFilesToggle}
|
||||
className={showFiles ? activeButtonClass : buttonClass}
|
||||
aria-label="Toggle files"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
|
||||
{/* Members toggle */}
|
||||
<button
|
||||
onClick={onMembersToggle}
|
||||
className={showMembers ? activeButtonClass : buttonClass}
|
||||
aria-label="Toggle members"
|
||||
>
|
||||
<div className="relative">
|
||||
<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="absolute -top-1 -right-2 bg-gray-500 text-white text-[10px] rounded-full w-4 h-4 flex items-center justify-center">
|
||||
{memberCount > 9 ? '9+' : memberCount}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Desktop: Inline toolbar */
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Left side - action buttons */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Upload file */}
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={handleFileClick}
|
||||
disabled={uploadProgress !== null}
|
||||
className={buttonClass}
|
||||
title="Upload file"
|
||||
>
|
||||
{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>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
|
||||
</svg>
|
||||
<span>Upload</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Generate report */}
|
||||
<button
|
||||
onClick={onGenerateReport}
|
||||
disabled={isGeneratingReport}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-purple-600 hover:bg-purple-50 text-sm disabled:opacity-50"
|
||||
title="Generate AI report"
|
||||
>
|
||||
<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>
|
||||
<span>{isGeneratingReport ? 'Generating...' : 'Report'}</span>
|
||||
</button>
|
||||
|
||||
{/* Add member */}
|
||||
{canManageMembers && (
|
||||
<button
|
||||
onClick={onAddMemberClick}
|
||||
className={buttonClass}
|
||||
title="Add member"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
|
||||
</svg>
|
||||
<span>Add Member</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right side - panel toggles */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Files toggle */}
|
||||
<button
|
||||
onClick={onFilesToggle}
|
||||
className={showFiles ? activeButtonClass : buttonClass}
|
||||
title="Toggle files panel"
|
||||
>
|
||||
<svg className="w-4 h-4" 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>Files</span>
|
||||
</button>
|
||||
|
||||
{/* Members toggle */}
|
||||
<button
|
||||
onClick={onMembersToggle}
|
||||
className={showMembers ? activeButtonClass : buttonClass}
|
||||
title="Toggle members panel"
|
||||
>
|
||||
<svg className="w-4 h-4" 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>{memberCount}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile expanded actions */}
|
||||
{isMobile && (
|
||||
<div
|
||||
className={`overflow-hidden transition-all duration-200 ease-out ${
|
||||
expanded ? 'max-h-24 opacity-100 mt-2 pt-2 border-t border-gray-100' : 'max-h-0 opacity-0'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-around">
|
||||
{/* Upload file */}
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={() => {
|
||||
handleFileClick()
|
||||
setExpanded(false)
|
||||
}}
|
||||
disabled={uploadProgress !== null}
|
||||
className={buttonClass}
|
||||
>
|
||||
{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>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
|
||||
</svg>
|
||||
<span className="text-xs">Upload</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Generate report */}
|
||||
<button
|
||||
onClick={() => {
|
||||
onGenerateReport()
|
||||
setExpanded(false)
|
||||
}}
|
||||
disabled={isGeneratingReport}
|
||||
className="flex flex-col items-center justify-center p-2 rounded-lg text-purple-600 hover:bg-purple-50 active:bg-purple-100 touch-target disabled:opacity-50"
|
||||
>
|
||||
<svg className="w-5 h-5" 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>
|
||||
<span className="text-xs">{isGeneratingReport ? 'Generating' : 'Report'}</span>
|
||||
</button>
|
||||
|
||||
{/* Add member */}
|
||||
{canManageMembers && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onAddMemberClick()
|
||||
setExpanded(false)
|
||||
}}
|
||||
className={buttonClass}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
|
||||
</svg>
|
||||
<span className="text-xs">Add Member</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
278
frontend/src/components/chat/MentionInput.tsx
Normal file
278
frontend/src/components/chat/MentionInput.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import type { KeyboardEvent, ChangeEvent } from 'react'
|
||||
|
||||
interface Member {
|
||||
user_id: string
|
||||
display_name?: string
|
||||
role?: string
|
||||
}
|
||||
|
||||
interface MentionInputProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onSubmit: () => void
|
||||
members: Member[]
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
isMobile?: boolean
|
||||
}
|
||||
|
||||
export function MentionInput({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
members,
|
||||
placeholder = 'Type a message...',
|
||||
disabled = false,
|
||||
className = '',
|
||||
isMobile = false,
|
||||
}: MentionInputProps) {
|
||||
const [showMentionDropdown, setShowMentionDropdown] = useState(false)
|
||||
const [mentionQuery, setMentionQuery] = useState('')
|
||||
const [mentionStartIndex, setMentionStartIndex] = useState(-1)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Filter members based on mention query
|
||||
const filteredMembers = members.filter((member) => {
|
||||
const displayName = member.display_name || member.user_id
|
||||
const query = mentionQuery.toLowerCase()
|
||||
return (
|
||||
displayName.toLowerCase().includes(query) ||
|
||||
member.user_id.toLowerCase().includes(query)
|
||||
)
|
||||
})
|
||||
|
||||
// Reset selected index when filtered results change
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0)
|
||||
}, [filteredMembers.length])
|
||||
|
||||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
if (showMentionDropdown && dropdownRef.current) {
|
||||
const selectedElement = dropdownRef.current.children[selectedIndex] as HTMLElement
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({ block: 'nearest' })
|
||||
}
|
||||
}
|
||||
}, [selectedIndex, showMentionDropdown])
|
||||
|
||||
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value
|
||||
const cursorPos = e.target.selectionStart || 0
|
||||
|
||||
onChange(newValue)
|
||||
|
||||
// Check for @ trigger
|
||||
const textBeforeCursor = newValue.slice(0, cursorPos)
|
||||
const lastAtIndex = textBeforeCursor.lastIndexOf('@')
|
||||
|
||||
if (lastAtIndex !== -1) {
|
||||
// Check if @ is at start or preceded by whitespace
|
||||
const charBeforeAt = lastAtIndex > 0 ? newValue[lastAtIndex - 1] : ' '
|
||||
if (charBeforeAt === ' ' || charBeforeAt === '\n' || lastAtIndex === 0) {
|
||||
const queryText = textBeforeCursor.slice(lastAtIndex + 1)
|
||||
// Only show dropdown if query doesn't contain spaces (still typing the mention)
|
||||
if (!queryText.includes(' ')) {
|
||||
setMentionQuery(queryText)
|
||||
setMentionStartIndex(lastAtIndex)
|
||||
setShowMentionDropdown(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setShowMentionDropdown(false)
|
||||
setMentionQuery('')
|
||||
setMentionStartIndex(-1)
|
||||
}
|
||||
|
||||
const insertMention = useCallback((member: Member) => {
|
||||
const displayName = member.display_name || member.user_id
|
||||
const beforeMention = value.slice(0, mentionStartIndex)
|
||||
const afterMention = value.slice(mentionStartIndex + mentionQuery.length + 1) // +1 for @
|
||||
|
||||
// Insert mention with space after
|
||||
const newValue = `${beforeMention}@${displayName} ${afterMention}`
|
||||
onChange(newValue)
|
||||
|
||||
// Close dropdown
|
||||
setShowMentionDropdown(false)
|
||||
setMentionQuery('')
|
||||
setMentionStartIndex(-1)
|
||||
|
||||
// Focus input and set cursor position
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
const newCursorPos = beforeMention.length + displayName.length + 2 // @name + space
|
||||
setTimeout(() => {
|
||||
inputRef.current?.setSelectionRange(newCursorPos, newCursorPos)
|
||||
}, 0)
|
||||
}
|
||||
}, [value, mentionStartIndex, mentionQuery, onChange])
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (showMentionDropdown && filteredMembers.length > 0) {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) =>
|
||||
prev < filteredMembers.length - 1 ? prev + 1 : 0
|
||||
)
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : filteredMembers.length - 1
|
||||
)
|
||||
break
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
insertMention(filteredMembers[selectedIndex])
|
||||
break
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
setShowMentionDropdown(false)
|
||||
break
|
||||
case 'Tab':
|
||||
if (filteredMembers.length > 0) {
|
||||
e.preventDefault()
|
||||
insertMention(filteredMembers[selectedIndex])
|
||||
}
|
||||
break
|
||||
}
|
||||
} else if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
onSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(e.target as Node) &&
|
||||
inputRef.current &&
|
||||
!inputRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setShowMentionDropdown(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className={`w-full 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'
|
||||
} ${className}`}
|
||||
/>
|
||||
|
||||
{/* Mention Dropdown */}
|
||||
{showMentionDropdown && filteredMembers.length > 0 && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute bottom-full left-0 right-0 mb-1 bg-white border border-gray-200 rounded-lg shadow-lg max-h-48 overflow-y-auto z-50"
|
||||
>
|
||||
{filteredMembers.map((member, index) => {
|
||||
const displayName = member.display_name || member.user_id
|
||||
return (
|
||||
<button
|
||||
key={member.user_id}
|
||||
type="button"
|
||||
onClick={() => insertMention(member)}
|
||||
className={`w-full px-3 py-2 text-left flex items-center gap-2 hover:bg-gray-100 ${
|
||||
index === selectedIndex ? 'bg-blue-50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-gray-300 flex items-center justify-center text-gray-600 text-sm font-medium">
|
||||
{displayName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 truncate">
|
||||
{displayName}
|
||||
</div>
|
||||
{member.display_name && member.display_name !== member.user_id && (
|
||||
<div className="text-xs text-gray-500 truncate">
|
||||
{member.user_id}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{member.role && (
|
||||
<span className="text-xs text-gray-400 capitalize">
|
||||
{member.role}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No results message */}
|
||||
{showMentionDropdown && mentionQuery && filteredMembers.length === 0 && (
|
||||
<div className="absolute bottom-full left-0 right-0 mb-1 bg-white border border-gray-200 rounded-lg shadow-lg p-3 text-sm text-gray-500">
|
||||
No members found matching "@{mentionQuery}"
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to highlight @mentions in message content
|
||||
*/
|
||||
export function highlightMentions(content: string, currentUserId?: string): React.ReactNode {
|
||||
const mentionPattern = /@(\S+)/g
|
||||
const parts: React.ReactNode[] = []
|
||||
let lastIndex = 0
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = mentionPattern.exec(content)) !== null) {
|
||||
// Add text before the mention
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(content.slice(lastIndex, match.index))
|
||||
}
|
||||
|
||||
const mentionedName = match[1]
|
||||
const isCurrentUser = currentUserId &&
|
||||
(mentionedName.toLowerCase() === currentUserId.toLowerCase())
|
||||
|
||||
// Add the highlighted mention
|
||||
parts.push(
|
||||
<span
|
||||
key={match.index}
|
||||
className={`px-1 rounded ${
|
||||
isCurrentUser
|
||||
? 'bg-yellow-200 text-yellow-800 font-medium'
|
||||
: 'bg-blue-100 text-blue-700'
|
||||
}`}
|
||||
>
|
||||
@{mentionedName}
|
||||
</span>
|
||||
)
|
||||
|
||||
lastIndex = match.index + match[0].length
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if (lastIndex < content.length) {
|
||||
parts.push(content.slice(lastIndex))
|
||||
}
|
||||
|
||||
return <>{parts}</>
|
||||
}
|
||||
220
frontend/src/components/chat/NotificationSettings.tsx
Normal file
220
frontend/src/components/chat/NotificationSettings.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { notificationService } from '../../services/notification'
|
||||
import type { NotificationSettings as NotificationSettingsType } from '../../services/notification'
|
||||
|
||||
interface NotificationSettingsProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function NotificationSettings({ isOpen, onClose }: NotificationSettingsProps) {
|
||||
const [settings, setSettings] = useState<NotificationSettingsType>(notificationService.getSettings())
|
||||
const [permission, setPermission] = useState(notificationService.getPermission())
|
||||
const [requesting, setRequesting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSettings(notificationService.getSettings())
|
||||
setPermission(notificationService.getPermission())
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const handleRequestPermission = async () => {
|
||||
setRequesting(true)
|
||||
const newPermission = await notificationService.requestPermission()
|
||||
setPermission(newPermission)
|
||||
setRequesting(false)
|
||||
}
|
||||
|
||||
const handleToggle = (key: keyof NotificationSettingsType) => {
|
||||
const newSettings = { ...settings, [key]: !settings[key] }
|
||||
setSettings(newSettings)
|
||||
notificationService.saveSettings(newSettings)
|
||||
}
|
||||
|
||||
const handleTestSound = () => {
|
||||
notificationService.playSound(false)
|
||||
}
|
||||
|
||||
const handleTestMentionSound = () => {
|
||||
notificationService.playSound(true)
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 transition-opacity duration-200"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="notification-settings-title"
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4 p-6 transform transition-all duration-200"
|
||||
style={{ animation: 'modal-pop-in 0.2s ease-out' }}
|
||||
>
|
||||
<style>{`
|
||||
@keyframes modal-pop-in {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
`}</style>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 id="notification-settings-title" className="text-lg font-semibold text-gray-900">
|
||||
Notification Settings
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded-lg hover:bg-gray-100"
|
||||
aria-label="Close notification settings"
|
||||
>
|
||||
<svg className="w-6 h-6" 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>
|
||||
|
||||
{/* Permission Status */}
|
||||
<div className="mb-6 p-4 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Browser Notifications</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{permission === 'granted' ? (
|
||||
<span className="text-green-600">Enabled</span>
|
||||
) : permission === 'denied' ? (
|
||||
<span className="text-red-600">Blocked (enable in browser settings)</span>
|
||||
) : (
|
||||
<span className="text-yellow-600">Not yet requested</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{permission === 'default' && (
|
||||
<button
|
||||
onClick={handleRequestPermission}
|
||||
disabled={requesting}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 text-sm"
|
||||
>
|
||||
{requesting ? 'Requesting...' : 'Enable'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Settings Toggles */}
|
||||
<div className="space-y-4">
|
||||
{/* Push Notifications */}
|
||||
<label className="flex items-center justify-between cursor-pointer">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Push Notifications</div>
|
||||
<div className="text-sm text-gray-500">Show browser notifications for new messages</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.pushEnabled}
|
||||
onChange={() => handleToggle('pushEnabled')}
|
||||
className="sr-only"
|
||||
disabled={permission !== 'granted'}
|
||||
/>
|
||||
<div className={`w-11 h-6 rounded-full transition-colors ${
|
||||
settings.pushEnabled && permission === 'granted' ? 'bg-blue-600' : 'bg-gray-300'
|
||||
}`}>
|
||||
<div className={`w-5 h-5 rounded-full bg-white shadow transform transition-transform ${
|
||||
settings.pushEnabled && permission === 'granted' ? 'translate-x-5' : 'translate-x-0.5'
|
||||
} mt-0.5`} />
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Sound Notifications */}
|
||||
<label className="flex items-center justify-between cursor-pointer">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-gray-900">Sound Alerts</div>
|
||||
<div className="text-sm text-gray-500">Play sound for new messages</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleTestSound}
|
||||
className="text-xs text-blue-600 hover:text-blue-700 mr-3"
|
||||
>
|
||||
Test
|
||||
</button>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.soundEnabled}
|
||||
onChange={() => handleToggle('soundEnabled')}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div className={`w-11 h-6 rounded-full transition-colors ${
|
||||
settings.soundEnabled ? 'bg-blue-600' : 'bg-gray-300'
|
||||
}`}>
|
||||
<div className={`w-5 h-5 rounded-full bg-white shadow transform transition-transform ${
|
||||
settings.soundEnabled ? 'translate-x-5' : 'translate-x-0.5'
|
||||
} mt-0.5`} />
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Mention Sound */}
|
||||
<label className="flex items-center justify-between cursor-pointer">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-gray-900">Mention Alerts</div>
|
||||
<div className="text-sm text-gray-500">Special sound when @mentioned</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleTestMentionSound}
|
||||
className="text-xs text-blue-600 hover:text-blue-700 mr-3"
|
||||
>
|
||||
Test
|
||||
</button>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.mentionSoundEnabled}
|
||||
onChange={() => handleToggle('mentionSoundEnabled')}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div className={`w-11 h-6 rounded-full transition-colors ${
|
||||
settings.mentionSoundEnabled ? 'bg-blue-600' : 'bg-gray-300'
|
||||
}`}>
|
||||
<div className={`w-5 h-5 rounded-full bg-white shadow transform transition-transform ${
|
||||
settings.mentionSoundEnabled ? 'translate-x-5' : 'translate-x-0.5'
|
||||
} mt-0.5`} />
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Vibration */}
|
||||
<label className="flex items-center justify-between cursor-pointer">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Vibration</div>
|
||||
<div className="text-sm text-gray-500">Vibrate on mobile devices</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.vibrationEnabled}
|
||||
onChange={() => handleToggle('vibrationEnabled')}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div className={`w-11 h-6 rounded-full transition-colors ${
|
||||
settings.vibrationEnabled ? 'bg-blue-600' : 'bg-gray-300'
|
||||
}`}>
|
||||
<div className={`w-5 h-5 rounded-full bg-white shadow transform transition-transform ${
|
||||
settings.vibrationEnabled ? 'translate-x-5' : 'translate-x-0.5'
|
||||
} mt-0.5`} />
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="mt-6 text-xs text-gray-500">
|
||||
<p>Notifications are only shown when the browser tab is not focused.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ interface MobileHeaderProps {
|
||||
onGenerateReport: () => void
|
||||
onStatusChange: (status: RoomStatus) => void
|
||||
onPermanentDelete: () => void
|
||||
onNotificationSettings?: () => void
|
||||
}
|
||||
|
||||
const statusColors: Record<RoomStatus, string> = {
|
||||
@@ -30,6 +31,7 @@ export function MobileHeader({
|
||||
onGenerateReport,
|
||||
onStatusChange,
|
||||
onPermanentDelete,
|
||||
onNotificationSettings,
|
||||
}: MobileHeaderProps) {
|
||||
const [showMenu, setShowMenu] = useState(false)
|
||||
|
||||
@@ -122,6 +124,22 @@ export function MobileHeader({
|
||||
<span className="text-gray-900">{isGeneratingReport ? '生成中...' : '生成報告'}</span>
|
||||
</button>
|
||||
|
||||
{/* Notification Settings */}
|
||||
{onNotificationSettings && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onNotificationSettings()
|
||||
setShowMenu(false)
|
||||
}}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-gray-50 active:bg-gray-100"
|
||||
>
|
||||
<svg className="w-5 h-5 text-gray-600" 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>
|
||||
<span className="text-gray-900">Notification Settings</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Status Actions */}
|
||||
{canUpdateStatus && status === 'active' && (
|
||||
<>
|
||||
|
||||
141
frontend/src/components/report/ReportPreview.tsx
Normal file
141
frontend/src/components/report/ReportPreview.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { reportsService } from '../../services/reports'
|
||||
|
||||
interface ReportPreviewProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
roomId: string
|
||||
reportId: string
|
||||
reportTitle?: string | null
|
||||
onDownload: () => void
|
||||
}
|
||||
|
||||
export default function ReportPreview({
|
||||
isOpen,
|
||||
onClose,
|
||||
roomId,
|
||||
reportId,
|
||||
reportTitle,
|
||||
onDownload,
|
||||
}: ReportPreviewProps) {
|
||||
const [markdown, setMarkdown] = useState<string>('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && roomId && reportId) {
|
||||
loadMarkdown()
|
||||
}
|
||||
}, [isOpen, roomId, reportId])
|
||||
|
||||
const loadMarkdown = async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const response = await reportsService.getReportMarkdown(roomId, reportId)
|
||||
setMarkdown(response.markdown)
|
||||
} catch (err) {
|
||||
setError('Failed to load report content')
|
||||
console.error('Failed to load report markdown:', err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyMarkdown = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(markdown)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy markdown:', err)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] mx-4 flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900 truncate">
|
||||
{reportTitle || 'Report Preview'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100"
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
<span className="ml-3 text-gray-500">Loading report...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-500 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={loadMarkdown}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="prose prose-sm max-w-none prose-table:border-collapse prose-th:border prose-th:border-gray-300 prose-th:p-2 prose-th:bg-gray-100 prose-td:border prose-td:border-gray-300 prose-td:p-2">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{markdown}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 bg-gray-50">
|
||||
<button
|
||||
onClick={handleCopyMarkdown}
|
||||
disabled={isLoading || !!error}
|
||||
className="flex items-center gap-2 px-4 py-2 text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<svg className="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-green-600">Copied!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" 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>
|
||||
<span>Copy Markdown</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={onDownload}
|
||||
disabled={isLoading || !!error}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<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>
|
||||
<span>Download Word</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ReportStatus } from '../../types'
|
||||
import ReportPreview from './ReportPreview'
|
||||
|
||||
interface ReportProgressProps {
|
||||
isOpen: boolean
|
||||
@@ -12,6 +13,8 @@ interface ReportProgressProps {
|
||||
message: string
|
||||
error?: string
|
||||
reportId?: string
|
||||
roomId?: string
|
||||
reportTitle?: string | null
|
||||
onDownload?: () => void
|
||||
}
|
||||
|
||||
@@ -34,11 +37,13 @@ export default function ReportProgress({
|
||||
status,
|
||||
message,
|
||||
error,
|
||||
reportId: _reportId,
|
||||
reportId,
|
||||
roomId,
|
||||
reportTitle,
|
||||
onDownload,
|
||||
}: ReportProgressProps) {
|
||||
// reportId is available for future use (e.g., polling status)
|
||||
const [animatedStep, setAnimatedStep] = useState(0)
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
const currentStep = getStepIndex(status)
|
||||
const isCompleted = status === 'completed'
|
||||
const isFailed = status === 'failed'
|
||||
@@ -176,6 +181,14 @@ export default function ReportProgress({
|
||||
>
|
||||
關閉
|
||||
</button>
|
||||
{roomId && reportId && (
|
||||
<button
|
||||
onClick={() => setShowPreview(true)}
|
||||
className="px-4 py-2 text-sm font-medium text-purple-700 bg-purple-50 border border-purple-200 rounded-md hover:bg-purple-100"
|
||||
>
|
||||
預覽報告
|
||||
</button>
|
||||
)}
|
||||
{onDownload && (
|
||||
<button
|
||||
onClick={onDownload}
|
||||
@@ -194,6 +207,21 @@ export default function ReportProgress({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Report Preview Modal */}
|
||||
{roomId && reportId && (
|
||||
<ReportPreview
|
||||
isOpen={showPreview}
|
||||
onClose={() => setShowPreview(false)}
|
||||
roomId={roomId}
|
||||
reportId={reportId}
|
||||
reportTitle={reportTitle}
|
||||
onDownload={() => {
|
||||
onDownload?.()
|
||||
setShowPreview(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ export { useAuth } from './useAuth'
|
||||
export {
|
||||
useRooms,
|
||||
useRoom,
|
||||
useRoomTemplates,
|
||||
useRoomPermissions,
|
||||
useCreateRoom,
|
||||
useUpdateRoom,
|
||||
@@ -11,6 +10,8 @@ export {
|
||||
useUpdateMemberRole,
|
||||
useRemoveMember,
|
||||
useTransferOwnership,
|
||||
useAddLot,
|
||||
useRemoveLot,
|
||||
roomKeys,
|
||||
} from './useRooms'
|
||||
export {
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
* useReports Hook
|
||||
* React Query hooks for report generation and management
|
||||
*/
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { reportsService } from '../services/reports'
|
||||
import type { ReportGenerateRequest } from '../types'
|
||||
import type { ReportGenerateRequest, ReportStatus } from '../types'
|
||||
|
||||
// Query Keys
|
||||
const reportKeys = {
|
||||
@@ -90,3 +91,141 @@ export function useInvalidateReports(roomId: string) {
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// Status message translations
|
||||
const STATUS_MESSAGES: Record<ReportStatus, string> = {
|
||||
pending: '準備中...',
|
||||
collecting_data: '正在收集聊天室資料...',
|
||||
generating_content: 'AI 正在分析並生成報告內容...',
|
||||
assembling_document: '正在組裝報告文件...',
|
||||
completed: '報告生成完成!',
|
||||
failed: '報告生成失敗',
|
||||
}
|
||||
|
||||
// Polling configuration
|
||||
const POLL_INTERVAL = 2000 // 2 seconds
|
||||
const MAX_POLL_DURATION = 120000 // 2 minutes timeout
|
||||
|
||||
/**
|
||||
* Hook to poll report status until completed or failed
|
||||
* Used as a fallback when WebSocket updates are unreliable
|
||||
*/
|
||||
export function useReportPolling(roomId: string, reportId: string | null) {
|
||||
const [status, setStatus] = useState<ReportStatus | null>(null)
|
||||
const [message, setMessage] = useState<string>('')
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [isPolling, setIsPolling] = useState(false)
|
||||
const pollIntervalRef = useRef<number | null>(null)
|
||||
const pollStartTimeRef = useRef<number | null>(null)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current)
|
||||
pollIntervalRef.current = null
|
||||
}
|
||||
setIsPolling(false)
|
||||
pollStartTimeRef.current = null
|
||||
}, [])
|
||||
|
||||
const pollStatus = useCallback(async () => {
|
||||
if (!roomId || !reportId) return
|
||||
|
||||
// Check timeout
|
||||
if (pollStartTimeRef.current) {
|
||||
const elapsed = Date.now() - pollStartTimeRef.current
|
||||
if (elapsed > MAX_POLL_DURATION) {
|
||||
stopPolling()
|
||||
setErrorMessage('報告生成超時,請重試')
|
||||
setStatus('failed')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const report = await reportsService.getReport(roomId, reportId)
|
||||
setStatus(report.status)
|
||||
setMessage(STATUS_MESSAGES[report.status] || '')
|
||||
|
||||
if (report.status === 'completed' || report.status === 'failed') {
|
||||
stopPolling()
|
||||
if (report.status === 'failed') {
|
||||
setErrorMessage(report.error_message || '報告生成失敗')
|
||||
}
|
||||
// Refresh the reports list
|
||||
queryClient.invalidateQueries({ queryKey: reportKeys.list(roomId) })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to poll report status:', error)
|
||||
// Don't stop polling on temporary errors
|
||||
}
|
||||
}, [roomId, reportId, stopPolling, queryClient])
|
||||
|
||||
const startPolling = useCallback(() => {
|
||||
if (!reportId || isPolling) return
|
||||
|
||||
setIsPolling(true)
|
||||
setErrorMessage(null)
|
||||
pollStartTimeRef.current = Date.now()
|
||||
|
||||
// Poll immediately
|
||||
pollStatus()
|
||||
|
||||
// Then poll at interval
|
||||
pollIntervalRef.current = window.setInterval(pollStatus, POLL_INTERVAL)
|
||||
}, [reportId, isPolling, pollStatus])
|
||||
|
||||
// Cleanup on unmount or reportId change
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopPolling()
|
||||
}
|
||||
}, [stopPolling, reportId])
|
||||
|
||||
return {
|
||||
status,
|
||||
message,
|
||||
errorMessage,
|
||||
isPolling,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for generating report with automatic polling
|
||||
*/
|
||||
export function useGenerateReportWithPolling(roomId: string) {
|
||||
const generateReport = useGenerateReport(roomId)
|
||||
const [currentReportId, setCurrentReportId] = useState<string | null>(null)
|
||||
const polling = useReportPolling(roomId, currentReportId)
|
||||
|
||||
const generate = useCallback(
|
||||
async (options?: ReportGenerateRequest) => {
|
||||
try {
|
||||
const result = await generateReport.mutateAsync(options)
|
||||
setCurrentReportId(result.report_id)
|
||||
polling.startPolling()
|
||||
return result
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
},
|
||||
[generateReport, polling]
|
||||
)
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setCurrentReportId(null)
|
||||
polling.stopPolling()
|
||||
}, [polling])
|
||||
|
||||
return {
|
||||
generate,
|
||||
reset,
|
||||
isGenerating: generateReport.isPending || polling.isPolling,
|
||||
status: polling.status,
|
||||
message: polling.message,
|
||||
errorMessage: polling.errorMessage || (generateReport.error as Error)?.message,
|
||||
currentReportId,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@ vi.mock('../services/rooms', () => ({
|
||||
removeMember: vi.fn(),
|
||||
transferOwnership: vi.fn(),
|
||||
getPermissions: vi.fn(),
|
||||
getTemplates: vi.fn(),
|
||||
addLot: vi.fn(),
|
||||
removeLot: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ export const roomKeys = {
|
||||
list: (filters: RoomFilters) => [...roomKeys.lists(), filters] as const,
|
||||
details: () => [...roomKeys.all, 'detail'] as const,
|
||||
detail: (id: string) => [...roomKeys.details(), id] as const,
|
||||
templates: () => [...roomKeys.all, 'templates'] as const,
|
||||
permissions: (id: string) => [...roomKeys.all, 'permissions', id] as const,
|
||||
}
|
||||
|
||||
@@ -28,14 +27,6 @@ export function useRoom(roomId: string) {
|
||||
})
|
||||
}
|
||||
|
||||
export function useRoomTemplates() {
|
||||
return useQuery({
|
||||
queryKey: roomKeys.templates(),
|
||||
queryFn: () => roomsService.getTemplates(),
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
})
|
||||
}
|
||||
|
||||
export function useRoomPermissions(roomId: string) {
|
||||
return useQuery({
|
||||
queryKey: roomKeys.permissions(roomId),
|
||||
@@ -146,3 +137,27 @@ export function useTransferOwnership(roomId: string) {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useAddLot(roomId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (lot: string) => roomsService.addLot(roomId, lot),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: roomKeys.detail(roomId) })
|
||||
queryClient.invalidateQueries({ queryKey: roomKeys.lists() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useRemoveLot(roomId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (lot: string) => roomsService.removeLot(roomId, lot),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: roomKeys.detail(roomId) })
|
||||
queryClient.invalidateQueries({ queryKey: roomKeys.lists() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -29,26 +29,54 @@ export function useWebSocket(roomId: string | null, options?: UseWebSocketOption
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const reconnectTimeoutRef = useRef<number | null>(null)
|
||||
const reconnectDelayRef = useRef(RECONNECT_DELAY)
|
||||
const isConnectingRef = useRef(false)
|
||||
const currentRoomIdRef = useRef<string | null>(null)
|
||||
const disconnectTimeoutRef = useRef<number | null>(null)
|
||||
const shouldBeConnectedRef = useRef(false)
|
||||
|
||||
const token = useAuthStore((state) => state.token)
|
||||
const {
|
||||
setConnectionStatus,
|
||||
addMessage,
|
||||
updateMessage,
|
||||
removeMessage,
|
||||
setUserTyping,
|
||||
addOnlineUser,
|
||||
removeOnlineUser,
|
||||
} = useChatStore()
|
||||
// Use individual selectors to get stable function references
|
||||
const setConnectionStatus = useChatStore((state) => state.setConnectionStatus)
|
||||
const addMessage = useChatStore((state) => state.addMessage)
|
||||
const updateMessage = useChatStore((state) => state.updateMessage)
|
||||
const removeMessage = useChatStore((state) => state.removeMessage)
|
||||
const setUserTyping = useChatStore((state) => state.setUserTyping)
|
||||
const addOnlineUser = useChatStore((state) => state.addOnlineUser)
|
||||
const removeOnlineUser = useChatStore((state) => state.removeOnlineUser)
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (!roomId || !token) {
|
||||
return
|
||||
}
|
||||
|
||||
// Mark that we want to be connected (cancels pending disconnect)
|
||||
shouldBeConnectedRef.current = true
|
||||
if (disconnectTimeoutRef.current) {
|
||||
clearTimeout(disconnectTimeoutRef.current)
|
||||
disconnectTimeoutRef.current = null
|
||||
}
|
||||
|
||||
// Prevent multiple simultaneous connection attempts
|
||||
if (isConnectingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
// Don't reconnect if already connected to the same room
|
||||
if (
|
||||
wsRef.current &&
|
||||
wsRef.current.readyState === WebSocket.OPEN &&
|
||||
currentRoomIdRef.current === roomId
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
isConnectingRef.current = true
|
||||
currentRoomIdRef.current = roomId
|
||||
|
||||
// Close existing connection
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
|
||||
// Build WebSocket URL - use env variable or default to current host
|
||||
@@ -69,6 +97,7 @@ export function useWebSocket(roomId: string | null, options?: UseWebSocketOption
|
||||
const ws = new WebSocket(wsUrl)
|
||||
|
||||
ws.onopen = () => {
|
||||
isConnectingRef.current = false
|
||||
setConnectionStatus('connected')
|
||||
reconnectDelayRef.current = RECONNECT_DELAY
|
||||
}
|
||||
@@ -83,16 +112,23 @@ export function useWebSocket(roomId: string | null, options?: UseWebSocketOption
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
isConnectingRef.current = false
|
||||
setConnectionStatus('error')
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
isConnectingRef.current = false
|
||||
wsRef.current = null
|
||||
setConnectionStatus('disconnected')
|
||||
scheduleReconnect()
|
||||
// Only reconnect if we're still supposed to be connected to this room
|
||||
if (currentRoomIdRef.current === roomId) {
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
wsRef.current = ws
|
||||
}, [roomId, token, setConnectionStatus])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [roomId, token])
|
||||
|
||||
const handleMessage = useCallback(
|
||||
(data: unknown) => {
|
||||
@@ -106,6 +142,7 @@ export function useWebSocket(roomId: string | null, options?: UseWebSocketOption
|
||||
message_id: messageBroadcast.message_id,
|
||||
room_id: messageBroadcast.room_id,
|
||||
sender_id: messageBroadcast.sender_id,
|
||||
sender_display_name: messageBroadcast.sender_display_name,
|
||||
content: messageBroadcast.content,
|
||||
message_type: messageBroadcast.message_type,
|
||||
metadata: messageBroadcast.metadata,
|
||||
@@ -260,25 +297,43 @@ export function useWebSocket(roomId: string | null, options?: UseWebSocketOption
|
||||
)
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
// Mark that we want to disconnect
|
||||
shouldBeConnectedRef.current = false
|
||||
currentRoomIdRef.current = null
|
||||
|
||||
// Clear any pending reconnect
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
reconnectTimeoutRef.current = null
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
wsRef.current = null
|
||||
|
||||
// Clear any pending disconnect
|
||||
if (disconnectTimeoutRef.current) {
|
||||
clearTimeout(disconnectTimeoutRef.current)
|
||||
}
|
||||
|
||||
// Delay the actual disconnect to handle React StrictMode remount
|
||||
// If connect() is called again within 100ms, the disconnect will be cancelled
|
||||
disconnectTimeoutRef.current = window.setTimeout(() => {
|
||||
if (!shouldBeConnectedRef.current && wsRef.current) {
|
||||
wsRef.current.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
disconnectTimeoutRef.current = null
|
||||
}, 100)
|
||||
}, [])
|
||||
|
||||
// Connect when roomId changes
|
||||
// Connect when roomId or token changes
|
||||
useEffect(() => {
|
||||
if (roomId) {
|
||||
if (roomId && token) {
|
||||
connect()
|
||||
}
|
||||
|
||||
return () => {
|
||||
disconnect()
|
||||
}
|
||||
}, [roomId, connect, disconnect])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [roomId, token])
|
||||
|
||||
return {
|
||||
sendTextMessage,
|
||||
|
||||
@@ -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">
|
||||
|
||||
217
frontend/src/services/notification.ts
Normal file
217
frontend/src/services/notification.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Notification Service
|
||||
* Handles browser push notifications, sounds, and vibration for chat messages
|
||||
*/
|
||||
|
||||
// Notification permission states
|
||||
export type NotificationPermission = 'default' | 'granted' | 'denied'
|
||||
|
||||
// Notification settings stored in localStorage
|
||||
export interface NotificationSettings {
|
||||
soundEnabled: boolean
|
||||
pushEnabled: boolean
|
||||
vibrationEnabled: boolean
|
||||
mentionSoundEnabled: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: NotificationSettings = {
|
||||
soundEnabled: true,
|
||||
pushEnabled: true,
|
||||
vibrationEnabled: true,
|
||||
mentionSoundEnabled: true,
|
||||
}
|
||||
|
||||
const SETTINGS_KEY = 'task_reporter_notification_settings'
|
||||
|
||||
class NotificationService {
|
||||
private audio: HTMLAudioElement | null = null
|
||||
private mentionAudio: HTMLAudioElement | null = null
|
||||
|
||||
constructor() {
|
||||
// Initialize audio elements
|
||||
if (typeof window !== 'undefined') {
|
||||
// Default notification sound (a simple beep)
|
||||
this.audio = new Audio()
|
||||
this.audio.src = 'data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQoGAACBhYqFbF1fdJivrJBhNjVgodDbq2EcBj+a2teleXV8gIF/gH2FjJF6AkB6mq+iblYoL0F+oMTTpGYjEFKV0dyjhng9MUOF3P/Xl1xBK0J/u97GbT4jQ3u90MGBSCoWRLXg7MR1PSUkUans5JlaMBdClenv2YhaGABmqO/z0JR0MBpDjuvs1pNwIg9GoeXwz59vIxVWq+/s0ZdsIyBbm+rqyY5dFAtYpO3ozJJdDwlko+vix5JbDghmpeHiyI9VDhhuoeLYxI5aEBJ0ouDhxpJmJA5rmuLhvYtdHBZ0ouLTvYhnIhd1mNvNsIVeGRt6mN3VsYBiIRd/mNzUsYFlIBh+mt/XsIBnHxaLo+XWw41pJR92oufYwJNrKBZ3ouvYyJl0Khh1oeXYypVzKBd5purrz5l8Lx93oefmzJt8LBl8penmz5x+MRd1oejpzZ2CMhV3pO3nzp2INBVypPHoyqCILRFwo/Trx6OLKw5vpfbszKiMLA5xpvfqy6iOMQ1ro/nuzquQMQ5spfvvz6yTMw5spv7wyqySMQ5sqP/xxKyQMhRspP3vwq2SMxBupP3vwa6UNBFtp/zuv7KUOQ9up/ztv7GWOw9vp/3swLOWOQ1wp/zuv7SWOw9wpv7tvraYPA5xpv3twLWYOxBwp/3uvriaPA5xp/zuwLeaPA5xqP3uvriaPg9xqP3uvrmaPw5xqP7vvbmaPg5xqPzvvbmaQA5xqP7vvbmaQA5yqP3vvbibQQ1yqP3wvbibQA5yqP/wvbibQA5zqP3wvridQA5zqP7xvbidQQ5zqP7xvbmeQQ5zqP7wvLqeQg5zqf7wvbqfQw1zqf7wvbqfQg5zqf/xvbqfQw50qf7xvbufQw50qf/xvbugRQ50qf/xvruiRQ5zqf/xvbyhRg50qv/xvLuhRg90qv/xvbuhRQ50qv/yvbyhRg50q//yvLyjRw50q//yvLyjRw50q//zvLykSA50q//yvbylSA50rP/zvbylSQ50rP/zvbylSQ50rP/zvbyuTw50q//0vLyuTw10q//0u7uvUA50q//0u7uwUQ50q//0u7uwUA50q//0u7uwUQ10q//0u7uwUA10rP/0u7uxUQ50rP/0u7uxUQ10q//0u7uxUg10rP/0u7uxUg10rP/0u7uyUg50rP/0uruzUw10rP/0uruzUw50rP/0uruzUw50rP/0uruzVA50rP/0ubu0VQ10rP/0ubu0VQ50rP/0ubu1VQ50rP/0ubu1Vg10rP/0ubu1Vg50rP/0ubu1Vg50rP/0ubu2Vw10rP/0ubu2Vw50rP/0ubu2Vw50rP/0ubu2WA50rP/0ubu2WA50rP/0uLu3WQ50rP/0uLu3WQ10rP/0uLu3WQ50rP/0uLu3Wg50rP/0uLu3Wg10rP/0uLu3Wg50q//0uLu4Ww10rP/0t7u4Ww50q//0t7u4Ww10rP/0t7u4XA10q//0t7u4XA50q//0t7u5XA10rP/0t7u5XA50q//0t7u5XQ10rP/0t7u5XQ50q//0t7u5XQ10rP/0t7u5Xg50q//0t7q5Xg10rP/0t7q5Xg50q//0t7q5Xw10rP/0t7q6Xw50q//0t7q6Xw10rP/0trq6YA50q//0trq6YA10rP/0trq6YA50q//0trq6YA10rP/0trq6YQ50q//0trq6YQ10rP/0trq7YQ50q//0trq7YQ10rP/0trq7Yg50q//0trq7Yg10rP/0trq7Yg50q//0trq7Yg50rP/0trq7Yg50q//0trq7Yg50rP/0trq7Yw50q//0trq7Yw50rP/0trq7Yw50q//0trq7Yw50rP/0trq8ZA50q//0trq8ZA50rP/0trq8ZA50q//0trm8ZA50rP/0trm8ZQ50q//0trm8ZQ50rP/0trm8ZQ50q//0trm8ZQ='
|
||||
this.audio.volume = 0.5
|
||||
|
||||
// Mention notification sound (slightly different tone)
|
||||
this.mentionAudio = new Audio()
|
||||
this.mentionAudio.src = 'data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQoGAACBhYqFbF1fdJivrJBhNjVgodDbq2EcBj+a2teleXV8gIF/gH2FjJF6AkB6mq+iblYoL0F+oMTTpGYjEFKV0dyjhng9MUOF3P/Xl1xBK0J/u97GbT4jQ3u90MGBSCoWRLXg7MR1PSUkUans5JlaMBdClenv2YhaGABmqO/z0JR0MBpDjuvs1pNwIg9GoeXwz59vIxVWq+/s0ZdsIyBbm+rqyY5dFAtYpO3ozJJdDwlko+vix5JbDghmpeHiyI9VDhhuoeLYxI5aEBJ0ouDhxpJmJA5rmuLhvYtdHBZ0ouLTvYhnIhd1mNvNsIVeGRt6mN3VsYBiIRd/mNzUsYFlIBh+mt/XsIBnHxaLo+XWw41pJR92oufYwJNrKBZ3ouvYyJl0Khh1oeXYypVzKBd5purrz5l8Lx93oefmzJt8LBl8penmz5x+MRd1oejpzZ2CMhV3pO3nzp2INBVypPHoyqCILRFwo/Trx6OLKw5vpfbszKiMLA5xpvfqy6iOMQ1ro/nuzquQMQ5spfvvz6yTMw5spv7wyqySMQ5sqP/xxKyQMhRspP3vwq2SMxBupP3vwa6UNBFtp/zuv7KUOQ9up/ztv7GWOw9vp/3swLOWOQ1wp/zuv7SWOw9wpv7tvraYPA5xpv3twLWYOxBwp/3uvriaPA5xp/zuwLeaPA5xqP3uvriaPg9xqP3uvrmaPw5xqP7vvbmaPg5xqPzvvbmaQA5xqP7vvbmaQA5yqP3vvbibQQ1yqP3wvbibQA5yqP/wvbibQA5zqP3wvridQA5zqP7xvbidQQ5zqP7xvbmeQQ5zqP7wvLqeQg5zqf7wvbqfQw1zqf7wvbqfQg5zqf/xvbqfQw50qf7xvbufQw50qf/xvbugRQ50qf/xvruiRQ5zqf/xvbyhRg50qv/xvLuhRg90qv/xvbuhRQ50qv/yvbyhRg50q//yvLyjRw50q//yvLyjRw50q//zvLykSA50q//yvbylSA50rP/zvbylSQ50rP/zvbylSQ50rP/zvbyuTw50q//0vLyuTw10q//0u7uvUA50q//0u7uwUQ50q//0u7uwUA50q//0u7uwUQ10q//0u7uwUA10rP/0u7uxUQ50rP/0u7uxUQ10q//0u7uxUg10rP/0u7uxUg10rP/0u7uyUg50rP/0uruzUw10rP/0uruzUw50rP/0uruzUw50rP/0uruzVA50rP/0ubu0VQ10rP/0ubu0VQ50rP/0ubu1VQ50rP/0ubu1Vg10rP/0ubu1Vg50rP/0ubu1Vg50rP/0ubu2Vw10rP/0ubu2Vw50rP/0ubu2Vw50rP/0ubu2WA50rP/0ubu2WA50rP/0uLu3WQ50rP/0uLu3WQ10rP/0uLu3WQ50rP/0uLu3Wg50rP/0uLu3Wg10rP/0uLu3Wg50q//0uLu4Ww10rP/0t7u4Ww50q//0t7u4Ww10rP/0t7u4XA10q//0t7u4XA50q//0t7u5XA10rP/0t7u5XA50q//0t7u5XQ10rP/0t7u5XQ50q//0t7u5XQ10rP/0t7u5Xg50q//0t7q5Xg10rP/0t7q5Xg50q//0t7q5Xw10rP/0t7q6Xw50q//0t7q6Xw10rP/0trq6YA50q//0trq6YA10rP/0trq6YA50q//0trq6YA10rP/0trq6YQ50q//0trq6YQ10rP/0trq7YQ50q//0trq7YQ10rP/0trq7Yg50q//0trq7Yg10rP/0trq7Yg50q//0trq7Yg50rP/0trq7Yg50q//0trq7Yg50rP/0trq7Yw50q//0trq7Yw50rP/0trq7Yw50q//0trq7Yw50rP/0trq8ZA50q//0trq8ZA50rP/0trq8ZA50q//0trm8ZA50rP/0trm8ZQ50q//0trm8ZQ50rP/0trm8ZQ50q//0trm8ZQ='
|
||||
this.mentionAudio.volume = 0.7
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current notification permission status
|
||||
*/
|
||||
getPermission(): NotificationPermission {
|
||||
if (typeof Notification === 'undefined') {
|
||||
return 'denied'
|
||||
}
|
||||
return Notification.permission as NotificationPermission
|
||||
}
|
||||
|
||||
/**
|
||||
* Request notification permission from the user
|
||||
*/
|
||||
async requestPermission(): Promise<NotificationPermission> {
|
||||
if (typeof Notification === 'undefined') {
|
||||
return 'denied'
|
||||
}
|
||||
|
||||
try {
|
||||
const permission = await Notification.requestPermission()
|
||||
return permission as NotificationPermission
|
||||
} catch {
|
||||
return 'denied'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get notification settings from localStorage
|
||||
*/
|
||||
getSettings(): NotificationSettings {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return DEFAULT_SETTINGS
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(SETTINGS_KEY)
|
||||
if (stored) {
|
||||
return { ...DEFAULT_SETTINGS, ...JSON.parse(stored) }
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
|
||||
return DEFAULT_SETTINGS
|
||||
}
|
||||
|
||||
/**
|
||||
* Save notification settings to localStorage
|
||||
*/
|
||||
saveSettings(settings: Partial<NotificationSettings>): void {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
|
||||
const current = this.getSettings()
|
||||
const updated = { ...current, ...settings }
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(updated))
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a browser notification
|
||||
*/
|
||||
async showNotification(
|
||||
title: string,
|
||||
options: NotificationOptions & { roomId?: string } = {}
|
||||
): Promise<void> {
|
||||
const settings = this.getSettings()
|
||||
|
||||
if (!settings.pushEnabled) return
|
||||
if (this.getPermission() !== 'granted') return
|
||||
|
||||
// Don't show notification if tab is focused
|
||||
if (document.hasFocus()) return
|
||||
|
||||
try {
|
||||
const notification = new Notification(title, {
|
||||
icon: '/favicon.ico',
|
||||
badge: '/favicon.ico',
|
||||
tag: options.tag || 'message',
|
||||
...options,
|
||||
} as NotificationOptions)
|
||||
|
||||
// Handle click - focus the window and navigate to room
|
||||
notification.onclick = () => {
|
||||
window.focus()
|
||||
if (options.roomId) {
|
||||
window.location.href = `/rooms/${options.roomId}`
|
||||
}
|
||||
notification.close()
|
||||
}
|
||||
|
||||
// Auto-close after 5 seconds
|
||||
setTimeout(() => notification.close(), 5000)
|
||||
} catch {
|
||||
// Notification failed, ignore silently
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play notification sound
|
||||
*/
|
||||
playSound(isMention = false): void {
|
||||
const settings = this.getSettings()
|
||||
|
||||
if (isMention && settings.mentionSoundEnabled && this.mentionAudio) {
|
||||
this.mentionAudio.currentTime = 0
|
||||
this.mentionAudio.play().catch(() => {
|
||||
// Audio play failed (e.g., user hasn't interacted with page)
|
||||
})
|
||||
} else if (settings.soundEnabled && this.audio) {
|
||||
this.audio.currentTime = 0
|
||||
this.audio.play().catch(() => {
|
||||
// Audio play failed
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger device vibration
|
||||
*/
|
||||
vibrate(pattern: number | number[] = 200): void {
|
||||
const settings = this.getSettings()
|
||||
|
||||
if (!settings.vibrationEnabled) return
|
||||
if (typeof navigator === 'undefined' || !navigator.vibrate) return
|
||||
|
||||
try {
|
||||
navigator.vibrate(pattern)
|
||||
} catch {
|
||||
// Vibration not supported
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle new message notification
|
||||
*/
|
||||
notifyNewMessage(
|
||||
senderName: string,
|
||||
content: string,
|
||||
roomId: string,
|
||||
roomTitle: string,
|
||||
isMention = false
|
||||
): void {
|
||||
const settings = this.getSettings()
|
||||
|
||||
// Play sound
|
||||
if (isMention) {
|
||||
this.playSound(true)
|
||||
this.vibrate([100, 50, 100]) // Double vibration for mentions
|
||||
} else {
|
||||
this.playSound(false)
|
||||
this.vibrate(200)
|
||||
}
|
||||
|
||||
// Show browser notification
|
||||
if (settings.pushEnabled) {
|
||||
const title = isMention
|
||||
? `${senderName} mentioned you in ${roomTitle}`
|
||||
: `New message in ${roomTitle}`
|
||||
|
||||
const truncatedContent = content.length > 100
|
||||
? content.slice(0, 100) + '...'
|
||||
: content
|
||||
|
||||
this.showNotification(title, {
|
||||
body: truncatedContent,
|
||||
tag: `room-${roomId}`,
|
||||
roomId,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const notificationService = new NotificationService()
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
ReportGenerateResponse,
|
||||
Report,
|
||||
ReportListResponse,
|
||||
ReportMarkdownResponse,
|
||||
} from '../types'
|
||||
|
||||
export const reportsService = {
|
||||
@@ -45,6 +46,19 @@ export const reportsService = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Get report content as Markdown for preview
|
||||
*/
|
||||
async getReportMarkdown(
|
||||
roomId: string,
|
||||
reportId: string
|
||||
): Promise<ReportMarkdownResponse> {
|
||||
const response = await api.get<ReportMarkdownResponse>(
|
||||
`/rooms/${roomId}/reports/${reportId}/markdown`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Download report as .docx file
|
||||
*/
|
||||
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
CreateRoomRequest,
|
||||
UpdateRoomRequest,
|
||||
RoomMember,
|
||||
RoomTemplate,
|
||||
PermissionResponse,
|
||||
MemberRole,
|
||||
RoomStatus,
|
||||
@@ -143,10 +142,18 @@ export const roomsService = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Get room templates
|
||||
* Add a LOT batch number to room
|
||||
*/
|
||||
async getTemplates(): Promise<RoomTemplate[]> {
|
||||
const response = await api.get<RoomTemplate[]>('/rooms/templates')
|
||||
async addLot(roomId: string, lot: string): Promise<string[]> {
|
||||
const response = await api.post<string[]>(`/rooms/${roomId}/lots`, { lot })
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove a LOT batch number from room
|
||||
*/
|
||||
async removeLot(roomId: string, lot: string): Promise<string[]> {
|
||||
const response = await api.delete<string[]>(`/rooms/${roomId}/lots/${encodeURIComponent(lot)}`)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
58
frontend/src/stores/mentionStore.ts
Normal file
58
frontend/src/stores/mentionStore.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Store for tracking unread @mentions across rooms
|
||||
* Uses localStorage for persistence
|
||||
*/
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
interface MentionState {
|
||||
// Map of roomId -> unread mention count
|
||||
unreadMentions: Record<string, number>
|
||||
|
||||
// Add a mention for a room
|
||||
addMention: (roomId: string) => void
|
||||
|
||||
// Clear mentions for a room (when user views the room)
|
||||
clearMentions: (roomId: string) => void
|
||||
|
||||
// Get mention count for a room
|
||||
getMentionCount: (roomId: string) => number
|
||||
|
||||
// Get total unread mentions across all rooms
|
||||
getTotalMentions: () => number
|
||||
}
|
||||
|
||||
export const useMentionStore = create<MentionState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
unreadMentions: {},
|
||||
|
||||
addMention: (roomId: string) => {
|
||||
set((state) => ({
|
||||
unreadMentions: {
|
||||
...state.unreadMentions,
|
||||
[roomId]: (state.unreadMentions[roomId] || 0) + 1,
|
||||
},
|
||||
}))
|
||||
},
|
||||
|
||||
clearMentions: (roomId: string) => {
|
||||
set((state) => {
|
||||
const { [roomId]: _, ...rest } = state.unreadMentions
|
||||
return { unreadMentions: rest }
|
||||
})
|
||||
},
|
||||
|
||||
getMentionCount: (roomId: string) => {
|
||||
return get().unreadMentions[roomId] || 0
|
||||
},
|
||||
|
||||
getTotalMentions: () => {
|
||||
return Object.values(get().unreadMentions).reduce((sum, count) => sum + count, 0)
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'task_reporter_mentions',
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -37,6 +37,7 @@ export interface Room {
|
||||
location?: string | null
|
||||
description?: string | null
|
||||
resolution_notes?: string | null
|
||||
lots?: string[]
|
||||
created_by: string
|
||||
created_at: string
|
||||
resolved_at?: string | null
|
||||
@@ -58,7 +59,7 @@ export interface CreateRoomRequest {
|
||||
severity: SeverityLevel
|
||||
location?: string
|
||||
description?: string
|
||||
template?: string
|
||||
lots?: string[]
|
||||
}
|
||||
|
||||
export interface UpdateRoomRequest {
|
||||
@@ -68,6 +69,7 @@ export interface UpdateRoomRequest {
|
||||
location?: string
|
||||
description?: string
|
||||
resolution_notes?: string
|
||||
lots?: string[]
|
||||
}
|
||||
|
||||
export interface RoomListResponse {
|
||||
@@ -77,16 +79,6 @@ export interface RoomListResponse {
|
||||
offset: number
|
||||
}
|
||||
|
||||
export interface RoomTemplate {
|
||||
template_id: number
|
||||
name: string
|
||||
description?: string
|
||||
incident_type: IncidentType
|
||||
default_severity: SeverityLevel
|
||||
default_members?: Record<string, unknown>[]
|
||||
metadata_fields?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface PermissionResponse {
|
||||
role: MemberRole | null
|
||||
is_admin: boolean
|
||||
@@ -105,6 +97,7 @@ export interface Message {
|
||||
message_id: string
|
||||
room_id: string
|
||||
sender_id: string
|
||||
sender_display_name?: string | null // Display name from users table
|
||||
content: string
|
||||
message_type: MessageType
|
||||
metadata?: Record<string, unknown>
|
||||
@@ -201,6 +194,7 @@ export interface MessageBroadcast {
|
||||
message_id: string
|
||||
room_id: string
|
||||
sender_id: string
|
||||
sender_display_name?: string | null // Display name from users table
|
||||
content: string
|
||||
message_type: MessageType
|
||||
metadata?: Record<string, unknown>
|
||||
@@ -291,6 +285,12 @@ export interface ReportListResponse {
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface ReportMarkdownResponse {
|
||||
report_id: string
|
||||
report_title?: string | null
|
||||
markdown: string
|
||||
}
|
||||
|
||||
export interface ReportProgressBroadcast {
|
||||
type: 'report_progress'
|
||||
report_id: string
|
||||
|
||||
114
frontend/src/utils/datetime.ts
Normal file
114
frontend/src/utils/datetime.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Datetime utilities for GMT+8 (Taiwan time) formatting
|
||||
*
|
||||
* All timestamps in the backend are stored in UTC.
|
||||
* This module provides functions to display them in GMT+8 timezone.
|
||||
*/
|
||||
|
||||
const TIMEZONE = 'Asia/Taipei'
|
||||
|
||||
/**
|
||||
* Format datetime to GMT+8 with full date and time
|
||||
* @param date - Date object or ISO string
|
||||
* @returns Formatted string like "2025/12/07 14:30"
|
||||
*/
|
||||
export function formatDateTimeGMT8(date: Date | string): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date
|
||||
return d.toLocaleString('zh-TW', {
|
||||
timeZone: TIMEZONE,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time only to GMT+8
|
||||
* @param date - Date object or ISO string
|
||||
* @returns Formatted string like "14:30"
|
||||
*/
|
||||
export function formatTimeGMT8(date: Date | string): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date
|
||||
return d.toLocaleString('zh-TW', {
|
||||
timeZone: TIMEZONE,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date only to GMT+8
|
||||
* @param date - Date object or ISO string
|
||||
* @returns Formatted string like "12/07"
|
||||
*/
|
||||
export function formatDateGMT8(date: Date | string): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date
|
||||
return d.toLocaleString('zh-TW', {
|
||||
timeZone: TIMEZONE,
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given date is today (in GMT+8 timezone)
|
||||
* @param date - Date object or ISO string
|
||||
* @returns true if the date is today in Taiwan timezone
|
||||
*/
|
||||
export function isTodayGMT8(date: Date | string): boolean {
|
||||
const d = typeof date === 'string' ? new Date(date) : date
|
||||
const now = new Date()
|
||||
|
||||
// Get date strings in Taiwan timezone for comparison
|
||||
const dateStr = d.toLocaleDateString('en-CA', { timeZone: TIMEZONE })
|
||||
const todayStr = now.toLocaleDateString('en-CA', { timeZone: TIMEZONE })
|
||||
|
||||
return dateStr === todayStr
|
||||
}
|
||||
|
||||
/**
|
||||
* Format message timestamp - shows time only for today, date+time for older messages
|
||||
* @param date - Date object or ISO string
|
||||
* @returns Formatted string
|
||||
*/
|
||||
export function formatMessageTime(date: Date | string): string {
|
||||
if (isTodayGMT8(date)) {
|
||||
return formatTimeGMT8(date)
|
||||
}
|
||||
return `${formatDateGMT8(date)} ${formatTimeGMT8(date)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format relative time in Chinese
|
||||
* @param date - Date object or ISO string
|
||||
* @returns Relative time string like "3分鐘前", "2小時前", "昨天 14:30"
|
||||
*/
|
||||
export function formatRelativeTimeGMT8(date: Date | string): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - d.getTime()
|
||||
const diffMinutes = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMs / 3600000)
|
||||
const diffDays = Math.floor(diffMs / 86400000)
|
||||
|
||||
if (diffMinutes < 1) {
|
||||
return '剛剛'
|
||||
}
|
||||
if (diffMinutes < 60) {
|
||||
return `${diffMinutes}分鐘前`
|
||||
}
|
||||
if (diffHours < 24) {
|
||||
return `${diffHours}小時前`
|
||||
}
|
||||
if (diffDays === 1) {
|
||||
return `昨天 ${formatTimeGMT8(date)}`
|
||||
}
|
||||
if (diffDays < 7) {
|
||||
return `${diffDays}天前`
|
||||
}
|
||||
return formatDateTimeGMT8(date)
|
||||
}
|
||||
Reference in New Issue
Block a user