feat: Initial commit - Task Reporter incident response system

Complete implementation of the production line incident response system (生產線異常即時反應系統) including:

Backend (FastAPI):
- User authentication with AD integration and session management
- Chat room management (create, list, update, members, roles)
- Real-time messaging via WebSocket (typing indicators, reactions)
- File storage with MinIO (upload, download, image preview)

Frontend (React + Vite):
- Authentication flow with token management
- Room list with filtering, search, and pagination
- Real-time chat interface with WebSocket
- File upload with drag-and-drop and image preview
- Member management and room settings
- Breadcrumb navigation
- 53 unit tests (Vitest)

Specifications:
- authentication: AD auth, sessions, JWT tokens
- chat-room: rooms, members, templates
- realtime-messaging: WebSocket, messages, reactions
- file-storage: MinIO integration, file management
- frontend-core: React SPA structure

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
egg
2025-12-01 17:42:52 +08:00
commit c8966477b9
135 changed files with 23269 additions and 0 deletions

View File

@@ -0,0 +1,183 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { render } from '../test/test-utils'
import Login from './Login'
import { useAuthStore } from '../stores/authStore'
// Mock react-router
const mockNavigate = vi.fn()
vi.mock('react-router', async () => {
const actual = await vi.importActual('react-router')
return {
...actual,
useNavigate: () => mockNavigate,
}
})
// Mock authService
vi.mock('../services/auth', () => ({
authService: {
login: vi.fn(),
},
}))
import { authService } from '../services/auth'
describe('Login', () => {
beforeEach(() => {
vi.clearAllMocks()
useAuthStore.setState({
token: null,
user: null,
isAuthenticated: false,
})
})
describe('rendering', () => {
it('should render login form', () => {
render(<Login />)
expect(screen.getByText('Task Reporter')).toBeInTheDocument()
expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
expect(screen.getByLabelText(/password/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument()
})
it('should have empty inputs initially', () => {
render(<Login />)
expect(screen.getByLabelText(/email/i)).toHaveValue('')
expect(screen.getByLabelText(/password/i)).toHaveValue('')
})
})
describe('form interaction', () => {
it('should update input values when typing', async () => {
const user = userEvent.setup()
render(<Login />)
const emailInput = screen.getByLabelText(/email/i)
const passwordInput = screen.getByLabelText(/password/i)
await user.type(emailInput, 'test@example.com')
await user.type(passwordInput, 'password123')
expect(emailInput).toHaveValue('test@example.com')
expect(passwordInput).toHaveValue('password123')
})
it('should submit form with credentials', async () => {
vi.mocked(authService.login).mockResolvedValue({
token: 'test-token',
display_name: 'Test User',
})
const user = userEvent.setup()
render(<Login />)
await user.type(screen.getByLabelText(/email/i), 'testuser')
await user.type(screen.getByLabelText(/password/i), 'password123')
await user.click(screen.getByRole('button', { name: /login/i }))
await waitFor(() => {
expect(authService.login).toHaveBeenCalledWith({
username: 'testuser',
password: 'password123',
})
})
})
it('should navigate to home on successful login', async () => {
vi.mocked(authService.login).mockResolvedValue({
token: 'test-token',
display_name: 'Test User',
})
const user = userEvent.setup()
render(<Login />)
await user.type(screen.getByLabelText(/email/i), 'testuser')
await user.type(screen.getByLabelText(/password/i), 'password123')
await user.click(screen.getByRole('button', { name: /login/i }))
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/')
})
})
it('should update auth store on successful login', async () => {
vi.mocked(authService.login).mockResolvedValue({
token: 'test-token',
display_name: 'Test User',
})
const user = userEvent.setup()
render(<Login />)
await user.type(screen.getByLabelText(/email/i), 'testuser')
await user.type(screen.getByLabelText(/password/i), 'password123')
await user.click(screen.getByRole('button', { name: /login/i }))
await waitFor(() => {
const state = useAuthStore.getState()
expect(state.token).toBe('test-token')
expect(state.isAuthenticated).toBe(true)
})
})
})
describe('error handling', () => {
it('should display error message on login failure', async () => {
vi.mocked(authService.login).mockRejectedValue(new Error('Invalid credentials'))
const user = userEvent.setup()
render(<Login />)
await user.type(screen.getByLabelText(/email/i), 'testuser')
await user.type(screen.getByLabelText(/password/i), 'wrongpassword')
await user.click(screen.getByRole('button', { name: /login/i }))
await waitFor(() => {
expect(screen.getByText(/invalid credentials/i)).toBeInTheDocument()
})
})
})
describe('loading state', () => {
it('should show loading state during login', async () => {
// Make login hang to test loading state
vi.mocked(authService.login).mockImplementation(
() => new Promise(() => {}) // Never resolves
)
const user = userEvent.setup()
render(<Login />)
await user.type(screen.getByLabelText(/email/i), 'testuser')
await user.type(screen.getByLabelText(/password/i), 'password123')
await user.click(screen.getByRole('button', { name: /login/i }))
await waitFor(() => {
expect(screen.getByText(/logging in/i)).toBeInTheDocument()
})
})
it('should disable button during login', async () => {
vi.mocked(authService.login).mockImplementation(
() => new Promise(() => {})
)
const user = userEvent.setup()
render(<Login />)
await user.type(screen.getByLabelText(/email/i), 'testuser')
await user.type(screen.getByLabelText(/password/i), 'password123')
await user.click(screen.getByRole('button', { name: /login/i }))
await waitFor(() => {
expect(screen.getByRole('button')).toBeDisabled()
})
})
})
})

View File

@@ -0,0 +1,136 @@
import { useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import { useNavigate } from 'react-router'
import { useAuthStore } from '../stores/authStore'
import { authService } from '../services/auth'
export default function Login() {
const navigate = useNavigate()
const setAuth = useAuthStore((state) => state.setAuth)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const loginMutation = useMutation({
mutationFn: () => authService.login({ username, password }),
onSuccess: (data) => {
setAuth(data.token, data.display_name, username)
navigate('/')
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
loginMutation.mutate()
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="max-w-md w-full mx-4">
<div className="bg-white rounded-lg shadow-lg p-8">
{/* Header */}
<div className="text-center mb-8">
<h1 className="text-2xl font-bold text-gray-900">
Task Reporter
</h1>
<p className="text-gray-600 mt-2">
Production Line Incident Response System
</p>
</div>
{/* Login Form */}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Username */}
<div>
<label
htmlFor="username"
className="block text-sm font-medium text-gray-700 mb-1"
>
Email
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
placeholder="Enter your email"
required
autoComplete="username"
/>
</div>
{/* Password */}
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 mb-1"
>
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
placeholder="Enter your password"
required
autoComplete="current-password"
/>
</div>
{/* Error Message */}
{loginMutation.isError && (
<div className="bg-red-50 text-red-600 px-4 py-3 rounded-lg text-sm">
{loginMutation.error instanceof Error
? 'Invalid credentials. Please try again.'
: 'An error occurred. Please try again.'}
</div>
)}
{/* Submit Button */}
<button
type="submit"
disabled={loginMutation.isPending}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-lg font-medium hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loginMutation.isPending ? (
<span className="flex items-center justify-center">
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Logging in...
</span>
) : (
'Login'
)}
</button>
</form>
{/* Footer */}
<p className="text-center text-gray-500 text-sm mt-6">
Use your company credentials to login
</p>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,21 @@
import { Link } from 'react-router'
export default function NotFound() {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<h1 className="text-6xl font-bold text-gray-300 mb-4">404</h1>
<h2 className="text-2xl font-semibold text-gray-900 mb-2">Page Not Found</h2>
<p className="text-gray-600 mb-6">
The page you're looking for doesn't exist or has been moved.
</p>
<Link
to="/"
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 transition-colors"
>
Go to Home
</Link>
</div>
</div>
)
}

View File

@@ -0,0 +1,852 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useParams, Link } from 'react-router'
import {
useRoom,
useRoomPermissions,
useUpdateRoom,
useAddMember,
useUpdateMemberRole,
useRemoveMember,
} from '../hooks/useRooms'
import { useMessages } from '../hooks/useMessages'
import { useWebSocket } from '../hooks/useWebSocket'
import { useFiles, useUploadFile, useDeleteFile } from '../hooks/useFiles'
import { filesService } from '../services/files'
import { useChatStore } from '../stores/chatStore'
import { useAuthStore } from '../stores/authStore'
import { Breadcrumb } from '../components/common'
import type { SeverityLevel, RoomStatus, MemberRole, FileMetadata } from '../types'
const statusColors: Record<RoomStatus, string> = {
active: 'bg-green-100 text-green-800',
resolved: 'bg-blue-100 text-blue-800',
archived: 'bg-gray-100 text-gray-800',
}
const severityColors: Record<SeverityLevel, string> = {
low: 'bg-gray-100 text-gray-800',
medium: 'bg-yellow-100 text-yellow-800',
high: 'bg-orange-100 text-orange-800',
critical: 'bg-red-100 text-red-800',
}
const roleLabels: Record<MemberRole, string> = {
owner: 'Owner',
editor: 'Editor',
viewer: 'Viewer',
}
const QUICK_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉']
export default function RoomDetail() {
const { roomId } = useParams<{ roomId: string }>()
const user = useAuthStore((state) => state.user)
const { data: room, isLoading: roomLoading, error: roomError } = useRoom(roomId || '')
const { data: permissions } = useRoomPermissions(roomId || '')
const { data: messagesData, isLoading: messagesLoading } = useMessages(roomId || '', { limit: 50 })
const { messages, connectionStatus, typingUsers, onlineUsers, setMessages, setCurrentRoom } = useChatStore()
const { sendTextMessage, sendTyping, editMessage, deleteMessage, addReaction, removeReaction } = useWebSocket(roomId || null)
// Mutations
const updateRoom = useUpdateRoom(roomId || '')
const addMember = useAddMember(roomId || '')
const updateMemberRole = useUpdateMemberRole(roomId || '')
const removeMember = useRemoveMember(roomId || '')
// File hooks
const { data: filesData, isLoading: filesLoading } = useFiles(roomId || '')
const uploadFile = useUploadFile(roomId || '')
const deleteFile = useDeleteFile(roomId || '')
const [messageInput, setMessageInput] = useState('')
const [showMembers, setShowMembers] = useState(false)
const [showFiles, setShowFiles] = useState(false)
const [showAddMember, setShowAddMember] = useState(false)
const [editingMessageId, setEditingMessageId] = useState<string | null>(null)
const [editContent, setEditContent] = useState('')
const [showEmojiPickerFor, setShowEmojiPickerFor] = useState<string | null>(null)
const [uploadProgress, setUploadProgress] = useState<number | null>(null)
const [isDragging, setIsDragging] = useState(false)
const [previewFile, setPreviewFile] = useState<FileMetadata | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const [newMemberUsername, setNewMemberUsername] = useState('')
const [newMemberRole, setNewMemberRole] = useState<MemberRole>('viewer')
const messagesEndRef = useRef<HTMLDivElement>(null)
const typingTimeoutRef = useRef<number | null>(null)
// Initialize room
useEffect(() => {
if (roomId) {
setCurrentRoom(roomId)
}
return () => {
setCurrentRoom(null)
}
}, [roomId, setCurrentRoom])
// Load initial messages
useEffect(() => {
if (messagesData?.messages) {
setMessages(messagesData.messages)
}
}, [messagesData, setMessages])
// Auto-scroll to bottom
useEffect(() => {
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
sendTextMessage(messageInput.trim())
setMessageInput('')
sendTyping(false)
}
const handleStartEdit = (messageId: string, content: string) => {
setEditingMessageId(messageId)
setEditContent(content)
}
const handleCancelEdit = () => {
setEditingMessageId(null)
setEditContent('')
}
const handleSaveEdit = () => {
if (!editingMessageId || !editContent.trim()) return
editMessage(editingMessageId, editContent.trim())
setEditingMessageId(null)
setEditContent('')
}
const handleDeleteMessage = (messageId: string) => {
if (window.confirm('Are you sure you want to delete this message?')) {
deleteMessage(messageId)
}
}
const handleAddReaction = (messageId: string, emoji: string) => {
addReaction(messageId, emoji)
setShowEmojiPickerFor(null)
}
const handleRemoveReaction = (messageId: string, emoji: string) => {
removeReaction(messageId, emoji)
}
const handleStatusChange = (newStatus: RoomStatus) => {
if (window.confirm(`Are you sure you want to change the room status to "${newStatus}"?`)) {
updateRoom.mutate({ status: newStatus })
}
}
const handleAddMember = (e: React.FormEvent) => {
e.preventDefault()
if (!newMemberUsername.trim()) return
addMember.mutate(
{ userId: newMemberUsername.trim(), role: newMemberRole },
{
onSuccess: () => {
setNewMemberUsername('')
setNewMemberRole('viewer')
setShowAddMember(false)
},
}
)
}
const handleRoleChange = (userId: string, newRole: MemberRole) => {
updateMemberRole.mutate({ userId, role: newRole })
}
const handleRemoveMember = (userId: string) => {
if (window.confirm(`Are you sure you want to remove this member?`)) {
removeMember.mutate(userId)
}
}
// File handlers
const handleFileUpload = useCallback(
(files: FileList | null) => {
if (!files || files.length === 0) return
const file = files[0]
setUploadProgress(0)
uploadFile.mutate(
{
file,
onProgress: (progress) => setUploadProgress(progress),
},
{
onSuccess: () => {
setUploadProgress(null)
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
},
onError: () => {
setUploadProgress(null)
},
}
)
},
[uploadFile]
)
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
handleFileUpload(e.dataTransfer.files)
},
[handleFileUpload]
)
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(true)
}, [])
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
}, [])
const handleDeleteFile = (fileId: string) => {
if (window.confirm('Are you sure you want to delete this file?')) {
deleteFile.mutate(fileId)
}
}
const handleDownloadFile = async (file: FileMetadata) => {
if (file.download_url) {
window.open(file.download_url, '_blank')
} else if (roomId) {
await filesService.downloadFile(roomId, file.file_id)
}
}
if (roomLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
)
}
if (roomError || !room) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<p className="text-red-500 mb-4">Failed to load room</p>
<Link to="/" className="text-blue-600 hover:text-blue-700">
Back to Room List
</Link>
</div>
</div>
)
}
const typingUsersArray = Array.from(typingUsers).filter((u) => u !== user?.username)
const onlineUsersArray = Array.from(onlineUsers)
return (
<div className="min-h-screen bg-gray-50 flex flex-col">
{/* Header */}
<header className="bg-white shadow-sm flex-shrink-0">
<div className="max-w-7xl mx-auto px-4 py-3">
{/* Breadcrumb */}
<div className="mb-2">
<Breadcrumb
items={[
{ label: 'Home', href: '/' },
{ label: 'Rooms', href: '/' },
{ label: room.title },
]}
/>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div>
<h1 className="font-semibold text-gray-900">{room.title}</h1>
<div className="flex items-center gap-2 text-sm">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${statusColors[room.status]}`}>
{room.status}
</span>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${severityColors[room.severity]}`}>
{room.severity}
</span>
{room.location && <span className="text-gray-500">{room.location}</span>}
</div>
</div>
</div>
<div className="flex items-center gap-4">
{/* Connection Status */}
<div className="flex items-center gap-1">
<div
className={`w-2 h-2 rounded-full ${
connectionStatus === 'connected'
? 'bg-green-500'
: connectionStatus === 'connecting'
? 'bg-yellow-500'
: 'bg-red-500'
}`}
/>
<span className="text-xs text-gray-500">
{connectionStatus === 'connected' ? 'Connected' : connectionStatus}
</span>
</div>
{/* Status Actions (Owner only) */}
{permissions?.can_update_status && room.status === 'active' && (
<div className="flex items-center gap-2">
<button
onClick={() => handleStatusChange('resolved')}
className="px-2 py-1 text-xs bg-blue-100 text-blue-700 rounded hover:bg-blue-200"
>
Resolve
</button>
<button
onClick={() => handleStatusChange('archived')}
className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded hover:bg-gray-200"
>
Archive
</button>
</div>
)}
{/* Files Toggle */}
<button
onClick={() => {
setShowFiles(!showFiles)
setShowMembers(false)
}}
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={() => {
setShowMembers(!showMembers)
setShowFiles(false)
}}
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>
</header>
{/* Main Content */}
<div className="flex-1 flex overflow-hidden">
{/* Chat Area */}
<div className="flex-1 flex flex-col">
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messagesLoading ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"></div>
</div>
) : messages.length === 0 ? (
<div className="text-center py-8 text-gray-500">
No messages yet. Start the conversation!
</div>
) : (
messages.map((message) => {
const isOwnMessage = message.sender_id === user?.username
const isEditing = editingMessageId === message.message_id
return (
<div
key={message.message_id}
className={`flex ${isOwnMessage ? 'justify-end' : 'justify-start'} group`}
>
<div
className={`max-w-[70%] rounded-lg px-4 py-2 ${
isOwnMessage
? 'bg-blue-600 text-white'
: 'bg-white shadow-sm'
}`}
>
{!isOwnMessage && (
<div className="text-xs font-medium text-gray-500 mb-1">
{message.sender_id}
</div>
)}
{isEditing ? (
<div className="space-y-2">
<input
type="text"
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
className="w-full px-2 py-1 text-sm text-gray-900 bg-white border border-gray-300 rounded"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveEdit()
if (e.key === 'Escape') handleCancelEdit()
}}
/>
<div className="flex gap-2 text-xs">
<button
onClick={handleSaveEdit}
className="px-2 py-1 bg-green-500 text-white rounded hover:bg-green-600"
>
Save
</button>
<button
onClick={handleCancelEdit}
className="px-2 py-1 bg-gray-500 text-white rounded hover:bg-gray-600"
>
Cancel
</button>
</div>
</div>
) : (
<>
<p className={isOwnMessage ? 'text-white' : 'text-gray-900'}>
{message.content}
</p>
{/* Reactions Display */}
{message.reaction_counts && Object.keys(message.reaction_counts).length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{Object.entries(message.reaction_counts).map(([emoji, count]) => (
<button
key={emoji}
onClick={() => handleRemoveReaction(message.message_id, emoji)}
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-xs ${
isOwnMessage
? 'bg-blue-500 hover:bg-blue-400'
: 'bg-gray-100 hover:bg-gray-200'
}`}
title={`Remove ${emoji}`}
>
<span>{emoji}</span>
<span className={isOwnMessage ? 'text-blue-100' : 'text-gray-600'}>{count}</span>
</button>
))}
</div>
)}
<div className="flex items-center justify-between gap-2">
<div
className={`text-xs mt-1 ${
isOwnMessage ? 'text-blue-200' : 'text-gray-400'
}`}
>
{new Date(message.created_at).toLocaleTimeString()}
{message.edited_at && ' (edited)'}
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{/* Reaction Button */}
<div className="relative">
<button
onClick={() => setShowEmojiPickerFor(
showEmojiPickerFor === message.message_id ? null : message.message_id
)}
className={`p-1 ${isOwnMessage ? 'text-blue-200 hover:text-white' : 'text-gray-400 hover:text-gray-600'}`}
title="Add reaction"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
{/* Emoji Picker Dropdown */}
{showEmojiPickerFor === message.message_id && (
<div className={`absolute bottom-full mb-1 ${isOwnMessage ? 'right-0' : 'left-0'} bg-white shadow-lg rounded-lg p-2 z-10 flex gap-1`}>
{QUICK_EMOJIS.map((emoji) => (
<button
key={emoji}
onClick={() => handleAddReaction(message.message_id, emoji)}
className="w-7 h-7 flex items-center justify-center hover:bg-gray-100 rounded"
>
{emoji}
</button>
))}
</div>
)}
</div>
{/* Edit/Delete (own messages only) */}
{isOwnMessage && (
<>
<button
onClick={() => handleStartEdit(message.message_id, message.content)}
className="p-1 text-blue-200 hover:text-white"
title="Edit"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={() => handleDeleteMessage(message.message_id)}
className="p-1 text-blue-200 hover:text-red-300"
title="Delete"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</>
)}
</div>
</div>
</>
)}
</div>
</div>
)
})
)}
<div ref={messagesEndRef} />
</div>
{/* Typing Indicator */}
{typingUsersArray.length > 0 && (
<div className="px-4 py-2 text-sm text-gray-500">
{typingUsersArray.join(', ')} {typingUsersArray.length === 1 ? 'is' : 'are'} typing...
</div>
)}
{/* Message Input */}
{permissions?.can_write && (
<form onSubmit={handleSendMessage} className="p-4 bg-white border-t">
<div className="flex gap-2">
<input
type="text"
value={messageInput}
onChange={handleInputChange}
placeholder="Type a message..."
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
/>
<button
type="submit"
disabled={!messageInput.trim()}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Send
</button>
</div>
</form>
)}
</div>
{/* Members Sidebar */}
{showMembers && (
<div className="w-72 bg-white border-l flex-shrink-0 overflow-y-auto">
<div className="p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-gray-900">Members</h3>
{permissions?.can_manage_members && (
<button
onClick={() => setShowAddMember(!showAddMember)}
className="text-blue-600 hover:text-blue-700 text-sm"
>
+ Add
</button>
)}
</div>
{/* Add Member Form */}
{showAddMember && (
<form onSubmit={handleAddMember} className="mb-4 p-3 bg-gray-50 rounded-lg">
<input
type="text"
value={newMemberUsername}
onChange={(e) => setNewMemberUsername(e.target.value)}
placeholder="Username"
className="w-full px-3 py-1.5 text-sm border border-gray-300 rounded mb-2"
/>
<div className="flex gap-2">
<select
value={newMemberRole}
onChange={(e) => setNewMemberRole(e.target.value as MemberRole)}
className="flex-1 px-2 py-1.5 text-sm border border-gray-300 rounded"
>
<option value="viewer">Viewer</option>
<option value="editor">Editor</option>
</select>
<button
type="submit"
disabled={addMember.isPending}
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
>
Add
</button>
</div>
{addMember.isError && (
<p className="text-xs text-red-500 mt-1">Failed to add member</p>
)}
</form>
)}
{/* Member List */}
<div className="space-y-2">
{room.members?.map((member) => (
<div
key={member.user_id}
className="flex items-center justify-between py-2 px-2 hover:bg-gray-50 rounded"
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<div
className={`w-2 h-2 rounded-full flex-shrink-0 ${
onlineUsersArray.includes(member.user_id) ? 'bg-green-500' : 'bg-gray-300'
}`}
/>
<span className="text-sm text-gray-900 truncate">{member.user_id}</span>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{/* Role selector (Owner can change roles except their own) */}
{permissions?.role === 'owner' && member.role !== 'owner' ? (
<select
value={member.role}
onChange={(e) => handleRoleChange(member.user_id, e.target.value as MemberRole)}
className="text-xs px-1 py-0.5 border border-gray-200 rounded"
>
<option value="viewer">Viewer</option>
<option value="editor">Editor</option>
</select>
) : (
<span className="text-xs text-gray-500 px-1">{roleLabels[member.role]}</span>
)}
{/* Remove button (Owner/Editor can remove, but not the owner) */}
{permissions?.can_manage_members &&
member.role !== 'owner' &&
member.user_id !== user?.username && (
<button
onClick={() => handleRemoveMember(member.user_id)}
className="p-1 text-gray-400 hover:text-red-500"
title="Remove member"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
))}
</div>
</div>
</div>
)}
{/* Files Sidebar */}
{showFiles && (
<div className="w-80 bg-white border-l flex-shrink-0 overflow-y-auto">
<div className="p-4">
<h3 className="font-semibold text-gray-900 mb-4">Files</h3>
{/* Upload Area */}
{permissions?.can_write && (
<div
className={`mb-4 border-2 border-dashed rounded-lg p-4 text-center transition-colors ${
isDragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300'
}`}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
>
<input
type="file"
ref={fileInputRef}
onChange={(e) => handleFileUpload(e.target.files)}
className="hidden"
/>
{uploadProgress !== null ? (
<div>
<div className="text-sm text-gray-600 mb-2">Uploading...</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all"
style={{ width: `${uploadProgress}%` }}
/>
</div>
<div className="text-xs text-gray-500 mt-1">{uploadProgress}%</div>
</div>
) : (
<>
<svg className="w-8 h-8 mx-auto text-gray-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p className="text-sm text-gray-600">
Drag & drop or{' '}
<button
onClick={() => fileInputRef.current?.click()}
className="text-blue-600 hover:text-blue-700"
>
browse
</button>
</p>
</>
)}
</div>
)}
{/* File List */}
{filesLoading ? (
<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 ? (
<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) => (
<div
key={file.file_id}
className="flex items-center gap-2 p-2 hover:bg-gray-50 rounded"
>
{/* Thumbnail or Icon */}
<div className="w-10 h-10 flex-shrink-0 rounded bg-gray-100 flex items-center justify-center overflow-hidden">
{filesService.isImage(file.mime_type) ? (
<svg className="w-5 h-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
) : (
<svg className="w-5 h-5 text-gray-400" 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>
)}
</div>
{/* File Info */}
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-900 truncate">{file.filename}</p>
<p className="text-xs text-gray-500">
{filesService.formatFileSize(file.file_size)}
</p>
</div>
{/* Actions */}
<div className="flex items-center gap-1">
{/* Preview button for images */}
{filesService.isImage(file.mime_type) && (
<button
onClick={() => setPreviewFile(file)}
className="p-1 text-gray-400 hover:text-blue-500"
title="Preview"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</button>
)}
{/* Download button */}
<button
onClick={() => handleDownloadFile(file)}
className="p-1 text-gray-400 hover:text-blue-500"
title="Download"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</button>
{/* Delete button */}
{(file.uploader_id === user?.username || permissions?.can_delete) && (
<button
onClick={() => handleDeleteFile(file.file_id)}
className="p-1 text-gray-400 hover:text-red-500"
title="Delete"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
</div>
{/* Image Preview Modal */}
{previewFile && (
<div
className="fixed inset-0 bg-black/80 flex items-center justify-center z-50"
onClick={() => setPreviewFile(null)}
>
<div className="relative max-w-4xl max-h-[90vh] m-4">
<button
onClick={() => setPreviewFile(null)}
className="absolute -top-10 right-0 text-white hover:text-gray-300"
>
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<img
src={previewFile.download_url || ''}
alt={previewFile.filename}
className="max-w-full max-h-[85vh] object-contain"
onClick={(e) => e.stopPropagation()}
/>
<div className="absolute -bottom-10 left-0 right-0 flex justify-center gap-4">
<span className="text-white text-sm">{previewFile.filename}</span>
<button
onClick={(e) => {
e.stopPropagation()
handleDownloadFile(previewFile)
}}
className="text-white hover:text-blue-400 text-sm"
>
Download
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,376 @@
import { useState } from 'react'
import { Link } from 'react-router'
import { useRooms, useCreateRoom, useRoomTemplates } from '../hooks/useRooms'
import { useAuthStore } from '../stores/authStore'
import { Breadcrumb } from '../components/common'
import type { RoomStatus, IncidentType, SeverityLevel, CreateRoomRequest } from '../types'
const statusColors: Record<RoomStatus, string> = {
active: 'bg-green-100 text-green-800',
resolved: 'bg-blue-100 text-blue-800',
archived: 'bg-gray-100 text-gray-800',
}
const severityColors: Record<SeverityLevel, string> = {
low: 'bg-gray-100 text-gray-800',
medium: 'bg-yellow-100 text-yellow-800',
high: 'bg-orange-100 text-orange-800',
critical: 'bg-red-100 text-red-800',
}
const incidentTypeLabels: Record<IncidentType, string> = {
equipment_failure: 'Equipment Failure',
material_shortage: 'Material Shortage',
quality_issue: 'Quality Issue',
other: 'Other',
}
const ITEMS_PER_PAGE = 12
export default function RoomList() {
const user = useAuthStore((state) => state.user)
const clearAuth = useAuthStore((state) => state.clearAuth)
const [statusFilter, setStatusFilter] = useState<RoomStatus | ''>('')
const [search, setSearch] = useState('')
const [showCreateModal, setShowCreateModal] = useState(false)
const [page, setPage] = useState(1)
// Reset page when filters change
const handleStatusChange = (status: RoomStatus | '') => {
setStatusFilter(status)
setPage(1)
}
const handleSearchChange = (searchValue: string) => {
setSearch(searchValue)
setPage(1)
}
const { data, isLoading, error } = useRooms({
status: statusFilter || undefined,
search: search || undefined,
limit: ITEMS_PER_PAGE,
offset: (page - 1) * ITEMS_PER_PAGE,
})
const totalPages = data ? Math.ceil(data.total / ITEMS_PER_PAGE) : 0
const handleLogout = () => {
clearAuth()
}
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white shadow-sm">
<div className="max-w-7xl mx-auto px-4 py-4 flex justify-between items-center">
<h1 className="text-xl font-bold text-gray-900">Task Reporter</h1>
<div className="flex items-center gap-4">
<span className="text-gray-600">{user?.display_name}</span>
<button
onClick={handleLogout}
className="text-gray-500 hover:text-gray-700"
>
Logout
</button>
</div>
</div>
</header>
{/* Main Content */}
<main className="max-w-7xl mx-auto px-4 py-6">
{/* Breadcrumb */}
<div className="mb-4">
<Breadcrumb items={[{ label: 'Home', href: '/' }, { label: 'Rooms' }]} />
</div>
{/* Toolbar */}
<div className="flex flex-col sm:flex-row gap-4 mb-6">
{/* Search */}
<div className="flex-1">
<input
type="text"
placeholder="Search rooms..."
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
/>
</div>
{/* Status Filter */}
<select
value={statusFilter}
onChange={(e) => handleStatusChange(e.target.value as RoomStatus | '')}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
>
<option value="">All Status</option>
<option value="active">Active</option>
<option value="resolved">Resolved</option>
<option value="archived">Archived</option>
</select>
{/* New Room Button */}
<button
onClick={() => setShowCreateModal(true)}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
>
+ New Room
</button>
</div>
{/* Room List */}
{isLoading ? (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-2 text-gray-500">Loading rooms...</p>
</div>
) : error ? (
<div className="text-center py-12">
<p className="text-red-500">Failed to load rooms</p>
</div>
) : data?.rooms.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-500">No rooms found</p>
<button
onClick={() => setShowCreateModal(true)}
className="mt-4 text-blue-600 hover:text-blue-700"
>
Create your first room
</button>
</div>
) : (
<>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{data?.rooms.map((room) => (
<Link
key={room.room_id}
to={`/rooms/${room.room_id}`}
className="bg-white rounded-lg shadow-sm border border-gray-200 p-4 hover:shadow-md transition-shadow"
>
{/* Room Header */}
<div className="flex justify-between items-start mb-2">
<h3 className="font-semibold text-gray-900 truncate flex-1">
{room.title}
</h3>
<span
className={`ml-2 px-2 py-0.5 rounded text-xs font-medium ${
statusColors[room.status]
}`}
>
{room.status}
</span>
</div>
{/* Type and Severity */}
<div className="flex gap-2 mb-3">
<span className="text-xs text-gray-500">
{incidentTypeLabels[room.incident_type]}
</span>
<span
className={`px-2 py-0.5 rounded text-xs font-medium ${
severityColors[room.severity]
}`}
>
{room.severity}
</span>
</div>
{/* Description */}
{room.description && (
<p className="text-sm text-gray-600 mb-3 line-clamp-2">
{room.description}
</p>
)}
{/* Footer */}
<div className="flex justify-between items-center text-xs text-gray-400">
<span>{room.member_count} members</span>
<span>
{new Date(room.last_activity_at).toLocaleDateString()}
</span>
</div>
</Link>
))}
</div>
{/* Pagination Controls */}
{totalPages > 1 && (
<div className="flex justify-center items-center gap-4 mt-8">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="px-4 py-2 rounded-lg border border-gray-300 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<span className="text-gray-600">
Page {page} of {totalPages}
<span className="text-gray-400 ml-2">
({data?.total} total)
</span>
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="px-4 py-2 rounded-lg border border-gray-300 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
)}
</>
)}
</main>
{/* Create Room Modal */}
{showCreateModal && (
<CreateRoomModal onClose={() => setShowCreateModal(false)} />
)}
</div>
)
}
// Create Room Modal Component
function CreateRoomModal({ onClose }: { onClose: () => void }) {
const [title, setTitle] = useState('')
const [incidentType, setIncidentType] = useState<IncidentType>('equipment_failure')
const [severity, setSeverity] = useState<SeverityLevel>('medium')
const [description, setDescription] = useState('')
const [location, setLocation] = useState('')
const createRoom = useCreateRoom()
// Templates loaded for future use (template selection feature)
useRoomTemplates()
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const data: CreateRoomRequest = {
title,
incident_type: incidentType,
severity,
description: description || undefined,
location: location || undefined,
}
createRoom.mutate(data, {
onSuccess: () => {
onClose()
},
})
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="p-6">
<h2 className="text-xl font-semibold mb-4">Create New Room</h2>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Title */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Title *
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
required
/>
</div>
{/* Incident Type */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Incident Type *
</label>
<select
value={incidentType}
onChange={(e) => setIncidentType(e.target.value as IncidentType)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
>
<option value="equipment_failure">Equipment Failure</option>
<option value="material_shortage">Material Shortage</option>
<option value="quality_issue">Quality Issue</option>
<option value="other">Other</option>
</select>
</div>
{/* Severity */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Severity *
</label>
<select
value={severity}
onChange={(e) => setSeverity(e.target.value as SeverityLevel)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="critical">Critical</option>
</select>
</div>
{/* Location */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Location
</label>
<input
type="text"
value={location}
onChange={(e) => setLocation(e.target.value)}
className="w-full 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., Line A, Station 3"
/>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
rows={3}
placeholder="Describe the incident..."
/>
</div>
{/* Error */}
{createRoom.isError && (
<div className="bg-red-50 text-red-600 px-3 py-2 rounded-lg text-sm">
Failed to create room. Please try again.
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-gray-600 hover:text-gray-800"
>
Cancel
</button>
<button
type="submit"
disabled={createRoom.isPending}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{createRoom.isPending ? 'Creating...' : 'Create Room'}
</button>
</div>
</form>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,4 @@
export { default as Login } from './Login'
export { default as RoomList } from './RoomList'
export { default as RoomDetail } from './RoomDetail'
export { default as NotFound } from './NotFound'