feat: Add mobile responsive layout, open room access, and admin room management
Mobile Responsive Layout: - Add useMediaQuery, useIsMobile, useIsTablet, useIsDesktop hooks for device detection - Create MobileHeader component with hamburger menu and action drawer - Create BottomToolbar for mobile navigation (Files, Members) - Create SlidePanel component for full-screen mobile sidebars - Update RoomDetail.tsx with mobile/desktop conditional rendering - Update RoomList.tsx with single-column grid and touch-friendly buttons - Add CSS custom properties for safe areas and touch targets (min 44px) - Add mobile viewport meta tags for notched devices Open Room Access: - All authenticated users can view all rooms (not just their own) - Users can join active rooms they're not members of - Add is_member field to room responses - Update room list API to return all rooms by default Admin Room Management: - Add permanent delete functionality for system admins - Add delete confirmation dialog with room title verification - Broadcast room deletion via WebSocket to connected users - Add users search API for adding members 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
26
app/main.py
26
app/main.py
@@ -11,6 +11,7 @@ from fastapi.responses import FileResponse
|
|||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.core.database import engine, Base
|
from app.core.database import engine, Base
|
||||||
from app.modules.auth import router as auth_router
|
from app.modules.auth import router as auth_router
|
||||||
|
from app.modules.auth.users_router import router as users_router
|
||||||
from app.modules.auth.middleware import auth_middleware
|
from app.modules.auth.middleware import auth_middleware
|
||||||
from app.modules.chat_room import router as chat_room_router
|
from app.modules.chat_room import router as chat_room_router
|
||||||
from app.modules.chat_room.services.template_service import template_service
|
from app.modules.chat_room.services.template_service import template_service
|
||||||
@@ -43,12 +44,12 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Authentication middleware (applies to all routes except login/logout)
|
# Authentication middleware (applies to all /api routes except login/logout)
|
||||||
# Note: Commented out for now to allow testing without auth
|
app.middleware("http")(auth_middleware)
|
||||||
# app.middleware("http")(auth_middleware)
|
|
||||||
|
|
||||||
# Include routers
|
# Include routers
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
|
app.include_router(users_router)
|
||||||
app.include_router(chat_room_router)
|
app.include_router(chat_room_router)
|
||||||
app.include_router(realtime_router)
|
app.include_router(realtime_router)
|
||||||
app.include_router(file_storage_router)
|
app.include_router(file_storage_router)
|
||||||
@@ -81,21 +82,14 @@ async def startup_event():
|
|||||||
logger.warning(f"MinIO connection failed: {e} - file uploads will be unavailable")
|
logger.warning(f"MinIO connection failed: {e} - file uploads will be unavailable")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/api/health")
|
||||||
async def root():
|
|
||||||
"""Health check endpoint"""
|
|
||||||
return {
|
|
||||||
"status": "ok",
|
|
||||||
"service": "Task Reporter API",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "生產線異常即時反應系統",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
|
||||||
async def health_check():
|
async def health_check():
|
||||||
"""Health check for monitoring"""
|
"""Health check for monitoring"""
|
||||||
return {"status": "healthy"}
|
return {
|
||||||
|
"status": "healthy",
|
||||||
|
"service": "Task Reporter API",
|
||||||
|
"version": "1.0.0",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# Serve frontend static files (only if build exists)
|
# Serve frontend static files (only if build exists)
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
3. AD token 自動刷新(5 分鐘內過期時)
|
3. AD token 自動刷新(5 分鐘內過期時)
|
||||||
4. 重試計數器管理(最多 3 次)
|
4. 重試計數器管理(最多 3 次)
|
||||||
"""
|
"""
|
||||||
from fastapi import Request, HTTPException, status
|
from fastapi import Request, status
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from app.core.database import SessionLocal
|
from app.core.database import SessionLocal
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
@@ -25,15 +26,19 @@ class AuthMiddleware:
|
|||||||
async def __call__(self, request: Request, call_next):
|
async def __call__(self, request: Request, call_next):
|
||||||
"""Process request through authentication checks"""
|
"""Process request through authentication checks"""
|
||||||
|
|
||||||
# Skip auth for login/logout endpoints
|
# Skip auth for non-API routes (frontend), login/logout, and docs
|
||||||
if request.url.path in ["/api/auth/login", "/api/auth/logout", "/docs", "/openapi.json"]:
|
path = request.url.path
|
||||||
|
if not path.startswith("/api") or path in ["/api/auth/login", "/api/auth/logout", "/api/health"]:
|
||||||
|
return await call_next(request)
|
||||||
|
if path in ["/docs", "/openapi.json", "/redoc"]:
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
# Extract token from Authorization header
|
# Extract token from Authorization header
|
||||||
authorization = request.headers.get("Authorization")
|
authorization = request.headers.get("Authorization")
|
||||||
if not authorization or not authorization.startswith("Bearer "):
|
if not authorization or not authorization.startswith("Bearer "):
|
||||||
raise HTTPException(
|
return JSONResponse(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required"
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
content={"detail": "Authentication required"}
|
||||||
)
|
)
|
||||||
|
|
||||||
internal_token = authorization.replace("Bearer ", "")
|
internal_token = authorization.replace("Bearer ", "")
|
||||||
@@ -44,32 +49,35 @@ class AuthMiddleware:
|
|||||||
# Query session
|
# Query session
|
||||||
user_session = session_service.get_session_by_token(db, internal_token)
|
user_session = session_service.get_session_by_token(db, internal_token)
|
||||||
if not user_session:
|
if not user_session:
|
||||||
raise HTTPException(
|
return JSONResponse(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token"
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
content={"detail": "Invalid or expired token"}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check 3-day inactivity timeout
|
# Check 3-day inactivity timeout
|
||||||
inactivity_limit = datetime.utcnow() - timedelta(days=settings.SESSION_INACTIVITY_DAYS)
|
inactivity_limit = datetime.utcnow() - timedelta(days=settings.SESSION_INACTIVITY_DAYS)
|
||||||
if user_session.last_activity < inactivity_limit:
|
if user_session.last_activity < inactivity_limit:
|
||||||
session_service.delete_session(db, user_session.id)
|
session_service.delete_session(db, user_session.id)
|
||||||
raise HTTPException(
|
return JSONResponse(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Session expired due to inactivity. Please login again.",
|
content={"detail": "Session expired due to inactivity. Please login again."}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if refresh attempts exceeded
|
# Check if refresh attempts exceeded
|
||||||
if user_session.refresh_attempt_count >= settings.MAX_REFRESH_ATTEMPTS:
|
if user_session.refresh_attempt_count >= settings.MAX_REFRESH_ATTEMPTS:
|
||||||
session_service.delete_session(db, user_session.id)
|
session_service.delete_session(db, user_session.id)
|
||||||
raise HTTPException(
|
return JSONResponse(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Session expired due to authentication failures. Please login again.",
|
content={"detail": "Session expired due to authentication failures. Please login again."}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if AD token needs refresh (< 5 minutes until expiry)
|
# Check if AD token needs refresh (< 5 minutes until expiry)
|
||||||
time_until_expiry = user_session.ad_token_expires_at - datetime.utcnow()
|
time_until_expiry = user_session.ad_token_expires_at - datetime.utcnow()
|
||||||
if time_until_expiry < timedelta(minutes=settings.TOKEN_REFRESH_THRESHOLD_MINUTES):
|
if time_until_expiry < timedelta(minutes=settings.TOKEN_REFRESH_THRESHOLD_MINUTES):
|
||||||
# Auto-refresh AD token
|
# Auto-refresh AD token
|
||||||
await self._refresh_ad_token(db, user_session)
|
refresh_error = await self._refresh_ad_token(db, user_session)
|
||||||
|
if refresh_error:
|
||||||
|
return refresh_error
|
||||||
|
|
||||||
# Update last_activity
|
# Update last_activity
|
||||||
session_service.update_activity(db, user_session.id)
|
session_service.update_activity(db, user_session.id)
|
||||||
@@ -87,7 +95,11 @@ class AuthMiddleware:
|
|||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
async def _refresh_ad_token(self, db, user_session):
|
async def _refresh_ad_token(self, db, user_session):
|
||||||
"""Auto-refresh AD token using stored encrypted password"""
|
"""Auto-refresh AD token using stored encrypted password
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSONResponse on error, None on success
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Decrypt password
|
# Decrypt password
|
||||||
password = encryption_service.decrypt_password(user_session.encrypted_password)
|
password = encryption_service.decrypt_password(user_session.encrypted_password)
|
||||||
@@ -101,6 +113,7 @@ class AuthMiddleware:
|
|||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"AD token refreshed successfully for user: {user_session.username}")
|
logger.info(f"AD token refreshed successfully for user: {user_session.username}")
|
||||||
|
return None # Success
|
||||||
|
|
||||||
except (ValueError, ConnectionError) as e:
|
except (ValueError, ConnectionError) as e:
|
||||||
# Refresh failed, increment counter
|
# Refresh failed, increment counter
|
||||||
@@ -117,14 +130,14 @@ class AuthMiddleware:
|
|||||||
logger.error(
|
logger.error(
|
||||||
f"Session terminated for {user_session.username} after {new_count} failed refresh attempts"
|
f"Session terminated for {user_session.username} after {new_count} failed refresh attempts"
|
||||||
)
|
)
|
||||||
raise HTTPException(
|
return JSONResponse(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Session terminated. Your password may have been changed. Please login again.",
|
content={"detail": "Session terminated. Your password may have been changed. Please login again."}
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise HTTPException(
|
return JSONResponse(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Token refresh failed. Please try again or re-login if issue persists.",
|
content={"detail": "Token refresh failed. Please try again or re-login if issue persists."}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -87,3 +87,31 @@ def get_display_name(db: Session, user_id: str) -> str:
|
|||||||
if user:
|
if user:
|
||||||
return user.display_name
|
return user.display_name
|
||||||
return user_id # Fallback to email if user not in database
|
return user_id # Fallback to email if user not in database
|
||||||
|
|
||||||
|
|
||||||
|
def search_users(db: Session, query: str, limit: int = 20) -> list[User]:
|
||||||
|
"""Search users by display_name or user_id (email)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
query: Search query string
|
||||||
|
limit: Maximum number of results (default 20)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of matching users
|
||||||
|
"""
|
||||||
|
from sqlalchemy import or_
|
||||||
|
|
||||||
|
search_pattern = f"%{query}%"
|
||||||
|
|
||||||
|
return (
|
||||||
|
db.query(User)
|
||||||
|
.filter(
|
||||||
|
or_(
|
||||||
|
User.display_name.ilike(search_pattern),
|
||||||
|
User.user_id.ilike(search_pattern)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|||||||
52
app/modules/auth/users_router.py
Normal file
52
app/modules/auth/users_router.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"""User management API endpoints
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
- GET /api/users/search - Search users by name or email
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.modules.auth import get_current_user
|
||||||
|
from app.modules.auth.services.user_service import search_users
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/users", tags=["Users"])
|
||||||
|
|
||||||
|
|
||||||
|
class UserSearchResult(BaseModel):
|
||||||
|
"""User search result"""
|
||||||
|
user_id: str
|
||||||
|
display_name: str
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/search", response_model=List[UserSearchResult])
|
||||||
|
async def search_users_endpoint(
|
||||||
|
q: str = Query(..., min_length=1, description="Search query (name or email)"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Search users by display_name or email
|
||||||
|
|
||||||
|
Returns up to 20 matching users. Requires authentication.
|
||||||
|
Search is case-insensitive and matches partial strings.
|
||||||
|
"""
|
||||||
|
if not q or len(q.strip()) == 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Search query required"
|
||||||
|
)
|
||||||
|
|
||||||
|
users = search_users(db, q.strip(), limit=20)
|
||||||
|
|
||||||
|
return [
|
||||||
|
UserSearchResult(
|
||||||
|
user_id=user.user_id,
|
||||||
|
display_name=user.display_name
|
||||||
|
)
|
||||||
|
for user in users
|
||||||
|
]
|
||||||
@@ -5,6 +5,7 @@ FastAPI router with all room-related endpoints
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.modules.auth import get_current_user
|
from app.modules.auth import get_current_user
|
||||||
@@ -20,6 +21,7 @@ from app.modules.chat_room.dependencies import (
|
|||||||
require_admin,
|
require_admin,
|
||||||
get_user_effective_role
|
get_user_effective_role
|
||||||
)
|
)
|
||||||
|
from app.modules.realtime.websocket_manager import manager as ws_manager
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/rooms", tags=["Chat Rooms"])
|
router = APIRouter(prefix="/api/rooms", tags=["Chat Rooms"])
|
||||||
|
|
||||||
@@ -59,7 +61,8 @@ async def create_room(
|
|||||||
|
|
||||||
return schemas.RoomResponse(
|
return schemas.RoomResponse(
|
||||||
**room.__dict__,
|
**room.__dict__,
|
||||||
current_user_role=role
|
current_user_role=role,
|
||||||
|
is_member=True # Creator is always a member
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -69,13 +72,18 @@ async def list_rooms(
|
|||||||
incident_type: Optional[schemas.IncidentType] = None,
|
incident_type: Optional[schemas.IncidentType] = None,
|
||||||
severity: Optional[schemas.SeverityLevel] = None,
|
severity: Optional[schemas.SeverityLevel] = None,
|
||||||
search: Optional[str] = None,
|
search: Optional[str] = None,
|
||||||
all: bool = Query(False, description="Admin only: show all rooms"),
|
my_rooms: bool = Query(False, description="Filter to show only rooms where user is a member"),
|
||||||
limit: int = Query(20, ge=1, le=100),
|
limit: int = Query(20, ge=1, le=100),
|
||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: dict = Depends(get_current_user)
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""List rooms accessible to current user"""
|
"""List all rooms for authenticated user
|
||||||
|
|
||||||
|
Returns all rooms by default. Use my_rooms=true to filter to only rooms
|
||||||
|
where the current user is a member. Each room includes is_member and
|
||||||
|
current_user_role fields.
|
||||||
|
"""
|
||||||
user_email = current_user["username"]
|
user_email = current_user["username"]
|
||||||
is_admin = membership_service.is_system_admin(user_email)
|
is_admin = membership_service.is_system_admin(user_email)
|
||||||
|
|
||||||
@@ -85,21 +93,23 @@ async def list_rooms(
|
|||||||
incident_type=incident_type,
|
incident_type=incident_type,
|
||||||
severity=severity,
|
severity=severity,
|
||||||
search=search,
|
search=search,
|
||||||
all=all,
|
my_rooms=my_rooms,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
offset=offset
|
offset=offset
|
||||||
)
|
)
|
||||||
|
|
||||||
rooms, total = room_service.list_user_rooms(db, user_email, filters, is_admin)
|
rooms, total = room_service.list_user_rooms(db, user_email, filters, is_admin)
|
||||||
|
|
||||||
# Add user role to each room
|
# Add user role and membership status to each room
|
||||||
room_responses = []
|
room_responses = []
|
||||||
for room in rooms:
|
for room in rooms:
|
||||||
role = membership_service.get_user_role_in_room(db, room.room_id, user_email)
|
role = membership_service.get_user_role_in_room(db, room.room_id, user_email)
|
||||||
|
is_member = role is not None
|
||||||
room_response = schemas.RoomResponse(
|
room_response = schemas.RoomResponse(
|
||||||
**room.__dict__,
|
**room.__dict__,
|
||||||
current_user_role=role,
|
current_user_role=role,
|
||||||
is_admin_view=is_admin and all
|
is_member=is_member,
|
||||||
|
is_admin_view=is_admin
|
||||||
)
|
)
|
||||||
room_responses.append(room_response)
|
room_responses.append(room_response)
|
||||||
|
|
||||||
@@ -125,11 +135,13 @@ async def get_room_details(
|
|||||||
member_responses = [schemas.MemberResponse.from_orm(m) for m in members]
|
member_responses = [schemas.MemberResponse.from_orm(m) for m in members]
|
||||||
|
|
||||||
is_admin = membership_service.is_system_admin(current_user["username"])
|
is_admin = membership_service.is_system_admin(current_user["username"])
|
||||||
|
is_member = role is not None
|
||||||
|
|
||||||
return schemas.RoomResponse(
|
return schemas.RoomResponse(
|
||||||
**room.__dict__,
|
**room.__dict__,
|
||||||
members=member_responses,
|
members=member_responses,
|
||||||
current_user_role=role,
|
current_user_role=role,
|
||||||
|
is_member=is_member,
|
||||||
is_admin_view=is_admin
|
is_admin_view=is_admin
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -152,7 +164,7 @@ async def update_room(
|
|||||||
)
|
)
|
||||||
|
|
||||||
role = membership_service.get_user_role_in_room(db, room_id, current_user["username"])
|
role = membership_service.get_user_role_in_room(db, room_id, current_user["username"])
|
||||||
return schemas.RoomResponse(**room.__dict__, current_user_role=role)
|
return schemas.RoomResponse(**room.__dict__, current_user_role=role, is_member=True)
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -178,6 +190,88 @@ async def delete_room(
|
|||||||
return schemas.SuccessResponse(message="Room archived successfully")
|
return schemas.SuccessResponse(message="Room archived successfully")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{room_id}/permanent", response_model=schemas.SuccessResponse)
|
||||||
|
async def permanent_delete_room(
|
||||||
|
room_id: str,
|
||||||
|
_: None = Depends(require_admin),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Permanently delete a room and all associated data (admin only)
|
||||||
|
|
||||||
|
This is an irreversible operation that deletes:
|
||||||
|
- All room members
|
||||||
|
- All messages and reactions
|
||||||
|
- All uploaded files (including MinIO storage)
|
||||||
|
- All generated reports (including MinIO storage)
|
||||||
|
- The room itself
|
||||||
|
|
||||||
|
Only system administrators can perform this operation.
|
||||||
|
"""
|
||||||
|
# Broadcast room_deleted event to all connected users BEFORE deleting
|
||||||
|
await ws_manager.broadcast_to_room(room_id, {
|
||||||
|
"type": "system",
|
||||||
|
"event": "room_deleted",
|
||||||
|
"room_id": room_id,
|
||||||
|
"timestamp": datetime.utcnow().isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
success, error = room_service.permanent_delete_room(db, room_id)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
if error == "Room not found":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Room not found"
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to delete room: {error}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return schemas.SuccessResponse(message="Room permanently deleted")
|
||||||
|
|
||||||
|
|
||||||
|
# Self-Join Endpoint
|
||||||
|
@router.post("/{room_id}/join", response_model=schemas.MemberResponse)
|
||||||
|
async def join_room(
|
||||||
|
room_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Self-join a room as a viewer
|
||||||
|
|
||||||
|
Any authenticated user can join any non-archived room.
|
||||||
|
User will be added with VIEWER role.
|
||||||
|
"""
|
||||||
|
user_email = current_user["username"]
|
||||||
|
|
||||||
|
member, error_code, existing = membership_service.self_join_room(
|
||||||
|
db, room_id, user_email
|
||||||
|
)
|
||||||
|
|
||||||
|
if error_code == "room_not_found":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Room not found"
|
||||||
|
)
|
||||||
|
elif error_code == "room_archived":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Cannot join archived room"
|
||||||
|
)
|
||||||
|
elif error_code == "already_member":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail={
|
||||||
|
"message": "Already a member of this room",
|
||||||
|
"current_role": existing.role.value,
|
||||||
|
"added_at": existing.added_at.isoformat()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return schemas.MemberResponse.from_orm(member)
|
||||||
|
|
||||||
|
|
||||||
# Membership Endpoints
|
# Membership Endpoints
|
||||||
@router.get("/{room_id}/members", response_model=List[schemas.MemberResponse])
|
@router.get("/{room_id}/members", response_model=List[schemas.MemberResponse])
|
||||||
async def list_room_members(
|
async def list_room_members(
|
||||||
@@ -224,10 +318,37 @@ async def update_member_role(
|
|||||||
room_id: str,
|
room_id: str,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
request: schemas.UpdateMemberRoleRequest,
|
request: schemas.UpdateMemberRoleRequest,
|
||||||
_: None = Depends(validate_room_owner),
|
db: Session = Depends(get_db),
|
||||||
db: Session = Depends(get_db)
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""Update a member's role"""
|
"""Update a member's role
|
||||||
|
|
||||||
|
Permission rules:
|
||||||
|
- OWNER can change any role
|
||||||
|
- EDITOR can upgrade VIEWER → EDITOR only
|
||||||
|
- EDITOR cannot downgrade, remove, or set OWNER role
|
||||||
|
"""
|
||||||
|
changer_id = current_user["username"]
|
||||||
|
|
||||||
|
# Get target member's current role
|
||||||
|
current_role = membership_service.get_user_role_in_room(db, room_id, user_id)
|
||||||
|
if not current_role:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Member not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check permission
|
||||||
|
allowed, error_msg = membership_service.can_change_member_role(
|
||||||
|
db, room_id, changer_id, user_id, current_role, request.role
|
||||||
|
)
|
||||||
|
|
||||||
|
if not allowed:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=error_msg
|
||||||
|
)
|
||||||
|
|
||||||
member = membership_service.update_member_role(
|
member = membership_service.update_member_role(
|
||||||
db,
|
db,
|
||||||
room_id,
|
room_id,
|
||||||
@@ -251,22 +372,27 @@ async def update_member_role(
|
|||||||
async def remove_member(
|
async def remove_member(
|
||||||
room_id: str,
|
room_id: str,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
_: None = Depends(require_room_permission("manage_members")),
|
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: dict = Depends(get_current_user)
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""Remove a member from the room"""
|
"""Remove a member from the room
|
||||||
# Prevent removing the last owner
|
|
||||||
if user_id == current_user["username"]:
|
Permission rules:
|
||||||
role = membership_service.get_user_role_in_room(db, room_id, user_id)
|
- OWNER can remove any member
|
||||||
if role == MemberRole.OWNER:
|
- EDITOR cannot remove members (only owner can)
|
||||||
# Check if there are other owners
|
- Users can remove themselves (leave room) unless they're the only owner
|
||||||
members = membership_service.get_room_members(db, room_id)
|
"""
|
||||||
owner_count = sum(1 for m in members if m.role == MemberRole.OWNER)
|
remover_id = current_user["username"]
|
||||||
if owner_count == 1:
|
|
||||||
|
# Check permission
|
||||||
|
allowed, error_msg = membership_service.can_remove_member(
|
||||||
|
db, room_id, remover_id, user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if not allowed:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Cannot remove the last owner"
|
detail=error_msg
|
||||||
)
|
)
|
||||||
|
|
||||||
success = membership_service.remove_member(db, room_id, user_id)
|
success = membership_service.remove_member(db, room_id, user_id)
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ class RoomFilterParams(BaseModel):
|
|||||||
created_after: Optional[datetime] = None
|
created_after: Optional[datetime] = None
|
||||||
created_before: Optional[datetime] = None
|
created_before: Optional[datetime] = None
|
||||||
search: Optional[str] = Field(None, description="Search in title and description")
|
search: Optional[str] = Field(None, description="Search in title and description")
|
||||||
all: Optional[bool] = Field(False, description="Admin: show all rooms")
|
my_rooms: Optional[bool] = Field(False, description="Filter to show only rooms where user is a member")
|
||||||
limit: int = Field(20, ge=1, le=100)
|
limit: int = Field(20, ge=1, le=100)
|
||||||
offset: int = Field(0, ge=0)
|
offset: int = Field(0, ge=0)
|
||||||
|
|
||||||
@@ -122,6 +122,7 @@ class RoomResponse(BaseModel):
|
|||||||
member_count: int
|
member_count: int
|
||||||
members: Optional[List[MemberResponse]] = None
|
members: Optional[List[MemberResponse]] = None
|
||||||
current_user_role: Optional[MemberRole] = None
|
current_user_role: Optional[MemberRole] = None
|
||||||
|
is_member: bool = False
|
||||||
is_admin_view: bool = False
|
is_admin_view: bool = False
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from sqlalchemy import and_
|
|||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from app.modules.chat_room.models import RoomMember, IncidentRoom, MemberRole
|
from app.modules.chat_room.models import RoomMember, IncidentRoom, MemberRole, RoomStatus
|
||||||
|
|
||||||
|
|
||||||
class MembershipService:
|
class MembershipService:
|
||||||
@@ -16,6 +16,73 @@ class MembershipService:
|
|||||||
# System admin email (hardcoded as per requirement)
|
# System admin email (hardcoded as per requirement)
|
||||||
SYSTEM_ADMIN_EMAIL = "ymirliu@panjit.com.tw"
|
SYSTEM_ADMIN_EMAIL = "ymirliu@panjit.com.tw"
|
||||||
|
|
||||||
|
def self_join_room(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
room_id: str,
|
||||||
|
user_id: str
|
||||||
|
) -> tuple[Optional[RoomMember], str, Optional[RoomMember]]:
|
||||||
|
"""Self-join a room as a viewer
|
||||||
|
|
||||||
|
Allows any authenticated user to join a room without invitation.
|
||||||
|
User joins as VIEWER role.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
room_id: Room ID
|
||||||
|
user_id: User joining
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (member, error_code, existing_member)
|
||||||
|
- On success: (member, "", None)
|
||||||
|
- If already member: (None, "already_member", existing_member)
|
||||||
|
- If room archived: (None, "room_archived", None)
|
||||||
|
- If room not found: (None, "room_not_found", None)
|
||||||
|
"""
|
||||||
|
# Check if room exists
|
||||||
|
room = db.query(IncidentRoom).filter(
|
||||||
|
IncidentRoom.room_id == room_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not room:
|
||||||
|
return None, "room_not_found", None
|
||||||
|
|
||||||
|
# Check if room is archived
|
||||||
|
if room.status == RoomStatus.ARCHIVED:
|
||||||
|
return None, "room_archived", None
|
||||||
|
|
||||||
|
# Check if already a member
|
||||||
|
existing = db.query(RoomMember).filter(
|
||||||
|
and_(
|
||||||
|
RoomMember.room_id == room_id,
|
||||||
|
RoomMember.user_id == user_id,
|
||||||
|
RoomMember.removed_at.is_(None)
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
return None, "already_member", existing
|
||||||
|
|
||||||
|
# Create membership as viewer
|
||||||
|
member = RoomMember(
|
||||||
|
room_id=room_id,
|
||||||
|
user_id=user_id,
|
||||||
|
role=MemberRole.VIEWER,
|
||||||
|
added_by=user_id, # Self-added
|
||||||
|
added_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
db.add(member)
|
||||||
|
|
||||||
|
# Update member count
|
||||||
|
self._update_member_count(db, room_id)
|
||||||
|
|
||||||
|
# Update room activity
|
||||||
|
room.last_activity_at = datetime.utcnow()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(member)
|
||||||
|
return member, "", None
|
||||||
|
|
||||||
def add_member(
|
def add_member(
|
||||||
self,
|
self,
|
||||||
db: Session,
|
db: Session,
|
||||||
@@ -101,6 +168,120 @@ class MembershipService:
|
|||||||
db.commit()
|
db.commit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def can_change_member_role(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
room_id: str,
|
||||||
|
changer_id: str,
|
||||||
|
target_id: str,
|
||||||
|
current_role: MemberRole,
|
||||||
|
new_role: MemberRole
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
"""Check if a user can change another member's role
|
||||||
|
|
||||||
|
Permission rules:
|
||||||
|
- OWNER can change any role
|
||||||
|
- EDITOR can upgrade VIEWER → EDITOR only
|
||||||
|
- EDITOR cannot downgrade, remove, or set OWNER role
|
||||||
|
- VIEWER cannot change roles
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
room_id: Room ID
|
||||||
|
changer_id: User attempting the change
|
||||||
|
target_id: Target user
|
||||||
|
current_role: Target's current role
|
||||||
|
new_role: Requested new role
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (allowed, error_message)
|
||||||
|
"""
|
||||||
|
# System admin can do anything
|
||||||
|
if self.is_system_admin(changer_id):
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
changer_role = self.get_user_role_in_room(db, room_id, changer_id)
|
||||||
|
|
||||||
|
if not changer_role:
|
||||||
|
return False, "Not a member of this room"
|
||||||
|
|
||||||
|
# Owner can change any role
|
||||||
|
if changer_role == MemberRole.OWNER:
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
# Editor permissions
|
||||||
|
if changer_role == MemberRole.EDITOR:
|
||||||
|
# Cannot set owner role
|
||||||
|
if new_role == MemberRole.OWNER:
|
||||||
|
return False, "Only owner can transfer ownership"
|
||||||
|
|
||||||
|
# Can only upgrade viewer to editor
|
||||||
|
if current_role == MemberRole.VIEWER and new_role == MemberRole.EDITOR:
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
# Cannot downgrade
|
||||||
|
if current_role == MemberRole.EDITOR and new_role == MemberRole.VIEWER:
|
||||||
|
return False, "Editors can only upgrade members"
|
||||||
|
|
||||||
|
return False, "Editors can only upgrade viewers to editor"
|
||||||
|
|
||||||
|
# Viewer cannot change roles
|
||||||
|
return False, "Insufficient permissions"
|
||||||
|
|
||||||
|
def can_remove_member(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
room_id: str,
|
||||||
|
remover_id: str,
|
||||||
|
target_id: str
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
"""Check if a user can remove another member
|
||||||
|
|
||||||
|
Permission rules:
|
||||||
|
- OWNER can remove any member
|
||||||
|
- EDITOR cannot remove members
|
||||||
|
- VIEWER cannot remove members
|
||||||
|
- Users can remove themselves (leave room)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
room_id: Room ID
|
||||||
|
remover_id: User attempting the removal
|
||||||
|
target_id: Target user
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (allowed, error_message)
|
||||||
|
"""
|
||||||
|
# System admin can do anything
|
||||||
|
if self.is_system_admin(remover_id):
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
remover_role = self.get_user_role_in_room(db, room_id, remover_id)
|
||||||
|
|
||||||
|
if not remover_role:
|
||||||
|
return False, "Not a member of this room"
|
||||||
|
|
||||||
|
# User can leave the room (remove themselves)
|
||||||
|
if remover_id == target_id:
|
||||||
|
# But owner cannot leave if they're the only owner
|
||||||
|
if remover_role == MemberRole.OWNER:
|
||||||
|
members = self.get_room_members(db, room_id)
|
||||||
|
owner_count = sum(1 for m in members if m.role == MemberRole.OWNER)
|
||||||
|
if owner_count == 1:
|
||||||
|
return False, "Cannot leave: you are the only owner"
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
# Owner can remove any member
|
||||||
|
if remover_role == MemberRole.OWNER:
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
# Editor cannot remove members
|
||||||
|
if remover_role == MemberRole.EDITOR:
|
||||||
|
return False, "Only owner can remove members"
|
||||||
|
|
||||||
|
# Viewer cannot remove members
|
||||||
|
return False, "Insufficient permissions"
|
||||||
|
|
||||||
def update_member_role(
|
def update_member_role(
|
||||||
self,
|
self,
|
||||||
db: Session,
|
db: Session,
|
||||||
|
|||||||
@@ -4,13 +4,16 @@ Handles business logic for room CRUD operations
|
|||||||
"""
|
"""
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import or_, and_, func
|
from sqlalchemy import or_, and_, func
|
||||||
from typing import List, Optional, Dict
|
from typing import List, Optional, Dict, Tuple
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import uuid
|
import uuid
|
||||||
|
import logging
|
||||||
|
|
||||||
from app.modules.chat_room.models import IncidentRoom, RoomMember, RoomStatus, MemberRole
|
from app.modules.chat_room.models import IncidentRoom, RoomMember, RoomStatus, MemberRole
|
||||||
from app.modules.chat_room.schemas import CreateRoomRequest, UpdateRoomRequest, RoomFilterParams
|
from app.modules.chat_room.schemas import CreateRoomRequest, UpdateRoomRequest, RoomFilterParams
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class RoomService:
|
class RoomService:
|
||||||
"""Service for room management operations"""
|
"""Service for room management operations"""
|
||||||
@@ -111,6 +114,10 @@ class RoomService:
|
|||||||
) -> List[IncidentRoom]:
|
) -> List[IncidentRoom]:
|
||||||
"""List rooms accessible to user with filters
|
"""List rooms accessible to user with filters
|
||||||
|
|
||||||
|
All authenticated users can see all rooms by default.
|
||||||
|
Use my_rooms=true to filter to only rooms where user is a member.
|
||||||
|
Non-admin users cannot see ARCHIVED rooms.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: Database session
|
db: Database session
|
||||||
user_id: User ID
|
user_id: User ID
|
||||||
@@ -118,13 +125,16 @@ class RoomService:
|
|||||||
is_admin: Whether user is system admin
|
is_admin: Whether user is system admin
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of accessible rooms
|
List of rooms and total count
|
||||||
"""
|
"""
|
||||||
|
# Non-admin requesting archived rooms explicitly - return empty
|
||||||
|
if not is_admin and filters.status == RoomStatus.ARCHIVED:
|
||||||
|
return [], 0
|
||||||
|
|
||||||
query = db.query(IncidentRoom)
|
query = db.query(IncidentRoom)
|
||||||
|
|
||||||
# Access control: admin sees all, others see only their rooms
|
# Filter to user's rooms only if my_rooms=true
|
||||||
if not is_admin or not filters.all:
|
if filters.my_rooms:
|
||||||
# Join with room_members to filter by membership
|
|
||||||
query = query.join(RoomMember).filter(
|
query = query.join(RoomMember).filter(
|
||||||
and_(
|
and_(
|
||||||
RoomMember.user_id == user_id,
|
RoomMember.user_id == user_id,
|
||||||
@@ -132,6 +142,10 @@ class RoomService:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Hide archived rooms from non-admin users
|
||||||
|
if not is_admin:
|
||||||
|
query = query.filter(IncidentRoom.status != RoomStatus.ARCHIVED)
|
||||||
|
|
||||||
# Apply filters
|
# Apply filters
|
||||||
if filters.status:
|
if filters.status:
|
||||||
query = query.filter(IncidentRoom.status == filters.status)
|
query = query.filter(IncidentRoom.status == filters.status)
|
||||||
@@ -381,6 +395,86 @@ class RoomService:
|
|||||||
room.last_activity_at = datetime.utcnow()
|
room.last_activity_at = datetime.utcnow()
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
def permanent_delete_room(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
room_id: str
|
||||||
|
) -> Tuple[bool, Optional[str]]:
|
||||||
|
"""Permanently delete a room and all associated data (admin only)
|
||||||
|
|
||||||
|
This is an irreversible operation that:
|
||||||
|
1. Deletes all files from MinIO storage
|
||||||
|
2. Deletes all report documents from MinIO storage
|
||||||
|
3. Cascades delete to all related database records
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
room_id: Room ID to permanently delete
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, error_message)
|
||||||
|
"""
|
||||||
|
# Late imports to avoid circular dependency
|
||||||
|
from app.modules.file_storage.models import RoomFile
|
||||||
|
from app.modules.report_generation.models import GeneratedReport
|
||||||
|
from app.modules.file_storage.services import minio_service
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
# Check room exists
|
||||||
|
room = db.query(IncidentRoom).filter(
|
||||||
|
IncidentRoom.room_id == room_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not room:
|
||||||
|
return False, "Room not found"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Step 1: Delete room files from MinIO
|
||||||
|
room_files = db.query(RoomFile).filter(
|
||||||
|
RoomFile.room_id == room_id
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for rf in room_files:
|
||||||
|
if rf.minio_object_path:
|
||||||
|
success = minio_service.delete_file(
|
||||||
|
rf.minio_bucket or settings.MINIO_BUCKET,
|
||||||
|
rf.minio_object_path
|
||||||
|
)
|
||||||
|
if not success:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to delete MinIO file: {rf.minio_object_path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 2: Delete generated report documents from MinIO
|
||||||
|
reports = db.query(GeneratedReport).filter(
|
||||||
|
GeneratedReport.room_id == room_id
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for report in reports:
|
||||||
|
if report.docx_storage_path:
|
||||||
|
success = minio_service.delete_file(
|
||||||
|
settings.MINIO_BUCKET,
|
||||||
|
report.docx_storage_path
|
||||||
|
)
|
||||||
|
if not success:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to delete report file: {report.docx_storage_path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 3: Delete room from database (CASCADE handles related tables)
|
||||||
|
db.delete(room)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Permanently deleted room {room_id} and all associated data")
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Failed to permanently delete room {room_id}: {e}")
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
|
||||||
# Create singleton instance
|
# Create singleton instance
|
||||||
room_service = RoomService()
|
room_service = RoomService()
|
||||||
@@ -13,8 +13,8 @@ class RoomFile(Base):
|
|||||||
# Primary key
|
# Primary key
|
||||||
file_id = Column(String(36), primary_key=True)
|
file_id = Column(String(36), primary_key=True)
|
||||||
|
|
||||||
# Foreign key to incident room
|
# Foreign key to incident room (CASCADE delete when room is permanently deleted)
|
||||||
room_id = Column(String(36), ForeignKey("incident_rooms.room_id"), nullable=False)
|
room_id = Column(String(36), ForeignKey("incident_rooms.room_id", ondelete="CASCADE"), nullable=False)
|
||||||
|
|
||||||
# File metadata
|
# File metadata
|
||||||
uploader_id = Column(String(255), nullable=False)
|
uploader_id = Column(String(255), nullable=False)
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import json
|
|||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.modules.auth.dependencies import get_current_user
|
from app.modules.auth.dependencies import get_current_user
|
||||||
|
from app.modules.auth.services.session_service import session_service
|
||||||
from app.modules.chat_room.models import RoomMember, MemberRole
|
from app.modules.chat_room.models import RoomMember, MemberRole
|
||||||
from app.modules.realtime.websocket_manager import manager
|
from app.modules.realtime.websocket_manager import manager, json_serializer
|
||||||
from app.modules.realtime.services.message_service import MessageService
|
from app.modules.realtime.services.message_service import MessageService
|
||||||
from app.modules.realtime.schemas import (
|
from app.modules.realtime.schemas import (
|
||||||
WebSocketMessageIn,
|
WebSocketMessageIn,
|
||||||
@@ -34,6 +35,11 @@ router = APIRouter(prefix="/api", tags=["realtime"])
|
|||||||
SYSTEM_ADMIN_EMAIL = "ymirliu@panjit.com.tw"
|
SYSTEM_ADMIN_EMAIL = "ymirliu@panjit.com.tw"
|
||||||
|
|
||||||
|
|
||||||
|
async def ws_send_json(websocket: WebSocket, data: dict):
|
||||||
|
"""Send JSON with custom datetime serializer"""
|
||||||
|
await websocket.send_text(json.dumps(data, default=json_serializer))
|
||||||
|
|
||||||
|
|
||||||
def get_user_room_membership(db: Session, room_id: str, user_id: str) -> Optional[RoomMember]:
|
def get_user_room_membership(db: Session, room_id: str, user_id: str) -> Optional[RoomMember]:
|
||||||
"""Check if user is a member of the room"""
|
"""Check if user is a member of the room"""
|
||||||
return db.query(RoomMember).filter(
|
return db.query(RoomMember).filter(
|
||||||
@@ -79,9 +85,17 @@ async def websocket_endpoint(
|
|||||||
db: Session = next(get_db())
|
db: Session = next(get_db())
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# For now, we'll extract user from cookie or token
|
# Authenticate token via session lookup
|
||||||
# TODO: Implement proper WebSocket token authentication
|
if not token:
|
||||||
user_id = token if token else "anonymous@example.com" # Placeholder
|
await websocket.close(code=4001, reason="Authentication required")
|
||||||
|
return
|
||||||
|
|
||||||
|
user_session = session_service.get_session_by_token(db, token)
|
||||||
|
if not user_session:
|
||||||
|
await websocket.close(code=4001, reason="Invalid or expired token")
|
||||||
|
return
|
||||||
|
|
||||||
|
user_id = user_session.username
|
||||||
|
|
||||||
# Check room membership
|
# Check room membership
|
||||||
membership = get_user_room_membership(db, room_id, user_id)
|
membership = get_user_room_membership(db, room_id, user_id)
|
||||||
@@ -114,7 +128,7 @@ async def websocket_endpoint(
|
|||||||
try:
|
try:
|
||||||
ws_message = WebSocketMessageIn(**message_data)
|
ws_message = WebSocketMessageIn(**message_data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await websocket.send_json(
|
await ws_send_json(websocket,
|
||||||
ErrorMessage(error=str(e), code="INVALID_MESSAGE").dict()
|
ErrorMessage(error=str(e), code="INVALID_MESSAGE").dict()
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
@@ -123,7 +137,7 @@ async def websocket_endpoint(
|
|||||||
if ws_message.type == WebSocketMessageType.MESSAGE:
|
if ws_message.type == WebSocketMessageType.MESSAGE:
|
||||||
# Check write permission
|
# Check write permission
|
||||||
if not can_write_message(membership, user_id):
|
if not can_write_message(membership, user_id):
|
||||||
await websocket.send_json(
|
await ws_send_json(websocket,
|
||||||
ErrorMessage(
|
ErrorMessage(
|
||||||
error="Insufficient permissions",
|
error="Insufficient permissions",
|
||||||
code="PERMISSION_DENIED"
|
code="PERMISSION_DENIED"
|
||||||
@@ -142,7 +156,7 @@ async def websocket_endpoint(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Send acknowledgment to sender
|
# Send acknowledgment to sender
|
||||||
await websocket.send_json(
|
await ws_send_json(websocket,
|
||||||
MessageAck(
|
MessageAck(
|
||||||
message_id=message.message_id,
|
message_id=message.message_id,
|
||||||
sequence_number=message.sequence_number,
|
sequence_number=message.sequence_number,
|
||||||
@@ -167,7 +181,7 @@ async def websocket_endpoint(
|
|||||||
|
|
||||||
elif ws_message.type == WebSocketMessageType.EDIT_MESSAGE:
|
elif ws_message.type == WebSocketMessageType.EDIT_MESSAGE:
|
||||||
if not ws_message.message_id or not ws_message.content:
|
if not ws_message.message_id or not ws_message.content:
|
||||||
await websocket.send_json(
|
await ws_send_json(websocket,
|
||||||
ErrorMessage(error="Missing message_id or content", code="INVALID_REQUEST").dict()
|
ErrorMessage(error="Missing message_id or content", code="INVALID_REQUEST").dict()
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
@@ -181,7 +195,7 @@ async def websocket_endpoint(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not edited_message:
|
if not edited_message:
|
||||||
await websocket.send_json(
|
await ws_send_json(websocket,
|
||||||
ErrorMessage(error="Cannot edit message", code="EDIT_FAILED").dict()
|
ErrorMessage(error="Cannot edit message", code="EDIT_FAILED").dict()
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
@@ -205,7 +219,7 @@ async def websocket_endpoint(
|
|||||||
|
|
||||||
elif ws_message.type == WebSocketMessageType.DELETE_MESSAGE:
|
elif ws_message.type == WebSocketMessageType.DELETE_MESSAGE:
|
||||||
if not ws_message.message_id:
|
if not ws_message.message_id:
|
||||||
await websocket.send_json(
|
await ws_send_json(websocket,
|
||||||
ErrorMessage(error="Missing message_id", code="INVALID_REQUEST").dict()
|
ErrorMessage(error="Missing message_id", code="INVALID_REQUEST").dict()
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
@@ -220,7 +234,7 @@ async def websocket_endpoint(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not deleted_message:
|
if not deleted_message:
|
||||||
await websocket.send_json(
|
await ws_send_json(websocket,
|
||||||
ErrorMessage(error="Cannot delete message", code="DELETE_FAILED").dict()
|
ErrorMessage(error="Cannot delete message", code="DELETE_FAILED").dict()
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
@@ -233,7 +247,7 @@ async def websocket_endpoint(
|
|||||||
|
|
||||||
elif ws_message.type == WebSocketMessageType.ADD_REACTION:
|
elif ws_message.type == WebSocketMessageType.ADD_REACTION:
|
||||||
if not ws_message.message_id or not ws_message.emoji:
|
if not ws_message.message_id or not ws_message.emoji:
|
||||||
await websocket.send_json(
|
await ws_send_json(websocket,
|
||||||
ErrorMessage(error="Missing message_id or emoji", code="INVALID_REQUEST").dict()
|
ErrorMessage(error="Missing message_id or emoji", code="INVALID_REQUEST").dict()
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
@@ -260,7 +274,7 @@ async def websocket_endpoint(
|
|||||||
|
|
||||||
elif ws_message.type == WebSocketMessageType.REMOVE_REACTION:
|
elif ws_message.type == WebSocketMessageType.REMOVE_REACTION:
|
||||||
if not ws_message.message_id or not ws_message.emoji:
|
if not ws_message.message_id or not ws_message.emoji:
|
||||||
await websocket.send_json(
|
await ws_send_json(websocket,
|
||||||
ErrorMessage(error="Missing message_id or emoji", code="INVALID_REQUEST").dict()
|
ErrorMessage(error="Missing message_id or emoji", code="INVALID_REQUEST").dict()
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
"""WebSocket connection pool management"""
|
"""WebSocket connection pool management"""
|
||||||
from fastapi import WebSocket
|
from fastapi import WebSocket
|
||||||
from typing import Dict, List, Set
|
from typing import Dict, List, Set, Any
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
|
|
||||||
|
def json_serializer(obj: Any) -> str:
|
||||||
|
"""Custom JSON serializer for objects not serializable by default json code"""
|
||||||
|
if isinstance(obj, datetime):
|
||||||
|
return obj.isoformat()
|
||||||
|
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
|
||||||
|
|
||||||
|
|
||||||
class ConnectionInfo:
|
class ConnectionInfo:
|
||||||
"""Information about a WebSocket connection"""
|
"""Information about a WebSocket connection"""
|
||||||
def __init__(self, websocket: WebSocket, user_id: str, room_id: str):
|
def __init__(self, websocket: WebSocket, user_id: str, room_id: str):
|
||||||
@@ -93,7 +100,7 @@ class WebSocketManager:
|
|||||||
if room_id not in self._room_connections:
|
if room_id not in self._room_connections:
|
||||||
return
|
return
|
||||||
|
|
||||||
message_json = json.dumps(message)
|
message_json = json.dumps(message, default=json_serializer)
|
||||||
|
|
||||||
# Collect disconnected connections
|
# Collect disconnected connections
|
||||||
disconnected = []
|
disconnected = []
|
||||||
@@ -124,7 +131,7 @@ class WebSocketManager:
|
|||||||
return
|
return
|
||||||
|
|
||||||
conn_info = self._user_connections[user_id]
|
conn_info = self._user_connections[user_id]
|
||||||
message_json = json.dumps(message)
|
message_json = json.dumps(message, default=json_serializer)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await conn_info.websocket.send_text(message_json)
|
await conn_info.websocket.send_text(message_json)
|
||||||
|
|||||||
@@ -3,8 +3,11 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<title>frontend</title>
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
<title>Task Reporter</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
65
frontend/src/components/mobile/BottomToolbar.tsx
Normal file
65
frontend/src/components/mobile/BottomToolbar.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
interface BottomToolbarProps {
|
||||||
|
showFiles: boolean
|
||||||
|
showMembers: boolean
|
||||||
|
memberCount: number
|
||||||
|
onFilesToggle: () => void
|
||||||
|
onMembersToggle: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BottomToolbar({
|
||||||
|
showFiles,
|
||||||
|
showMembers,
|
||||||
|
memberCount,
|
||||||
|
onFilesToggle,
|
||||||
|
onMembersToggle,
|
||||||
|
}: BottomToolbarProps) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white border-t flex items-center justify-around px-4 py-2 safe-area-bottom">
|
||||||
|
{/* Files button */}
|
||||||
|
<button
|
||||||
|
onClick={onFilesToggle}
|
||||||
|
className={`flex flex-col items-center justify-center touch-target px-4 py-1 rounded-lg ${
|
||||||
|
showFiles ? 'text-blue-600 bg-blue-50' : 'text-gray-600'
|
||||||
|
}`}
|
||||||
|
aria-label="Toggle files panel"
|
||||||
|
aria-pressed={showFiles}
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" 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-xs mt-0.5">Files</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Members button */}
|
||||||
|
<button
|
||||||
|
onClick={onMembersToggle}
|
||||||
|
className={`flex flex-col items-center justify-center touch-target px-4 py-1 rounded-lg ${
|
||||||
|
showMembers ? 'text-blue-600 bg-blue-50' : 'text-gray-600'
|
||||||
|
}`}
|
||||||
|
aria-label="Toggle members panel"
|
||||||
|
aria-pressed={showMembers}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<svg className="w-6 h-6" 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>
|
||||||
|
{/* Member count badge */}
|
||||||
|
<span className="absolute -top-1 -right-1 bg-gray-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">
|
||||||
|
{memberCount > 9 ? '9+' : memberCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs mt-0.5">Members</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
200
frontend/src/components/mobile/MobileHeader.tsx
Normal file
200
frontend/src/components/mobile/MobileHeader.tsx
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Link } from 'react-router'
|
||||||
|
import type { RoomStatus } from '../../types'
|
||||||
|
|
||||||
|
interface MobileHeaderProps {
|
||||||
|
title: string
|
||||||
|
status: RoomStatus
|
||||||
|
connectionStatus: 'connected' | 'connecting' | 'disconnected' | 'error'
|
||||||
|
canUpdateStatus?: boolean
|
||||||
|
isAdmin?: boolean
|
||||||
|
isGeneratingReport?: boolean
|
||||||
|
onGenerateReport: () => void
|
||||||
|
onStatusChange: (status: RoomStatus) => void
|
||||||
|
onPermanentDelete: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileHeader({
|
||||||
|
title,
|
||||||
|
status,
|
||||||
|
connectionStatus,
|
||||||
|
canUpdateStatus,
|
||||||
|
isAdmin,
|
||||||
|
isGeneratingReport,
|
||||||
|
onGenerateReport,
|
||||||
|
onStatusChange,
|
||||||
|
onPermanentDelete,
|
||||||
|
}: MobileHeaderProps) {
|
||||||
|
const [showMenu, setShowMenu] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Mobile Header Bar */}
|
||||||
|
<header className="bg-white shadow-sm flex-shrink-0 safe-area-top">
|
||||||
|
<div className="px-4 py-3 flex items-center justify-between">
|
||||||
|
{/* Back button and title */}
|
||||||
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="touch-target flex items-center justify-center -ml-2 text-gray-600"
|
||||||
|
aria-label="Back to room list"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h1 className="font-semibold text-gray-900 truncate text-base">{title}</h1>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs font-medium ${statusColors[status]}`}>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
{/* Connection indicator */}
|
||||||
|
<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' // disconnected or error
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hamburger menu button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowMenu(true)}
|
||||||
|
className="touch-target flex items-center justify-center text-gray-600"
|
||||||
|
aria-label="Open menu"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Action Drawer Overlay */}
|
||||||
|
{showMenu && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 bg-black/50"
|
||||||
|
onClick={() => setShowMenu(false)}
|
||||||
|
>
|
||||||
|
{/* Action Sheet */}
|
||||||
|
<div
|
||||||
|
className="absolute bottom-0 left-0 right-0 bg-white rounded-t-2xl safe-area-bottom animate-slide-up"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Handle bar */}
|
||||||
|
<div className="flex justify-center pt-3 pb-2">
|
||||||
|
<div className="w-10 h-1 bg-gray-300 rounded-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Menu items */}
|
||||||
|
<div className="px-4 pb-4 space-y-2">
|
||||||
|
{/* Generate Report */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onGenerateReport()
|
||||||
|
setShowMenu(false)
|
||||||
|
}}
|
||||||
|
disabled={isGeneratingReport}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-gray-50 active:bg-gray-100 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 text-purple-600" 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-gray-900">{isGeneratingReport ? '生成中...' : '生成報告'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Status Actions */}
|
||||||
|
{canUpdateStatus && status === 'active' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onStatusChange('resolved')
|
||||||
|
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-blue-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-gray-900">Mark as Resolved</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onStatusChange('archived')
|
||||||
|
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="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-gray-900">Archive</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Admin: Permanent Delete */}
|
||||||
|
{isAdmin && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onPermanentDelete()
|
||||||
|
setShowMenu(false)
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-red-50 active:bg-red-100"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 text-red-600" 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>
|
||||||
|
<span className="text-red-600">Delete Permanently</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cancel button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowMenu(false)}
|
||||||
|
className="w-full px-4 py-3 rounded-lg bg-gray-100 text-gray-700 font-medium hover:bg-gray-200 active:bg-gray-300 mt-2"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Animation keyframes - add to index.css if needed */}
|
||||||
|
<style>{`
|
||||||
|
@keyframes slide-up {
|
||||||
|
from {
|
||||||
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animate-slide-up {
|
||||||
|
animation: slide-up 0.3s ease-out;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
102
frontend/src/components/mobile/SlidePanel.tsx
Normal file
102
frontend/src/components/mobile/SlidePanel.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
interface SlidePanelProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
title: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SlidePanel({ isOpen, onClose, title, children }: SlidePanelProps) {
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Handle escape key
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && isOpen) {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleEscape)
|
||||||
|
return () => document.removeEventListener('keydown', handleEscape)
|
||||||
|
}, [isOpen, onClose])
|
||||||
|
|
||||||
|
// Prevent body scroll when panel is open
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
// Focus trap - focus panel when opened
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && panelRef.current) {
|
||||||
|
panelRef.current.focus()
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/50 transition-opacity"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Panel */}
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
className="absolute top-0 right-0 bottom-0 w-full max-w-sm bg-white shadow-xl flex flex-col animate-slide-in-right"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="panel-title"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b bg-white safe-area-top">
|
||||||
|
<h2 id="panel-title" className="text-lg font-semibold text-gray-900">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="touch-target flex items-center justify-center text-gray-500 hover:text-gray-700"
|
||||||
|
aria-label="Close panel"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto safe-area-bottom">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Animation styles */}
|
||||||
|
<style>{`
|
||||||
|
@keyframes slide-in-right {
|
||||||
|
from {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animate-slide-in-right {
|
||||||
|
animation: slide-in-right 0.3s ease-out;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
3
frontend/src/components/mobile/index.ts
Normal file
3
frontend/src/components/mobile/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { MobileHeader } from './MobileHeader'
|
||||||
|
export { BottomToolbar } from './BottomToolbar'
|
||||||
|
export { SlidePanel } from './SlidePanel'
|
||||||
@@ -38,3 +38,9 @@ export {
|
|||||||
useDownloadReport,
|
useDownloadReport,
|
||||||
useInvalidateReports,
|
useInvalidateReports,
|
||||||
} from './useReports'
|
} from './useReports'
|
||||||
|
export {
|
||||||
|
useMediaQuery,
|
||||||
|
useIsMobile,
|
||||||
|
useIsTablet,
|
||||||
|
useIsDesktop,
|
||||||
|
} from './useMediaQuery'
|
||||||
|
|||||||
174
frontend/src/hooks/useMediaQuery.test.ts
Normal file
174
frontend/src/hooks/useMediaQuery.test.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { renderHook, act } from '@testing-library/react'
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { useMediaQuery, useIsMobile, useIsTablet, useIsDesktop } from './useMediaQuery'
|
||||||
|
|
||||||
|
describe('useMediaQuery', () => {
|
||||||
|
let matchMediaMock: ReturnType<typeof vi.fn>
|
||||||
|
let listeners: Map<string, (e: MediaQueryListEvent) => void>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
listeners = new Map()
|
||||||
|
|
||||||
|
matchMediaMock = vi.fn((query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
addEventListener: vi.fn((event: string, handler: (e: MediaQueryListEvent) => void) => {
|
||||||
|
if (event === 'change') {
|
||||||
|
listeners.set(query, handler)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
removeEventListener: vi.fn((event: string) => {
|
||||||
|
if (event === 'change') {
|
||||||
|
listeners.delete(query)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: matchMediaMock,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
listeners.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return initial match state', () => {
|
||||||
|
matchMediaMock.mockImplementation((query: string) => ({
|
||||||
|
matches: query === '(max-width: 767px)',
|
||||||
|
media: query,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useMediaQuery('(max-width: 767px)'))
|
||||||
|
expect(result.current).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false when query does not match', () => {
|
||||||
|
matchMediaMock.mockImplementation(() => ({
|
||||||
|
matches: false,
|
||||||
|
media: '(max-width: 767px)',
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useMediaQuery('(max-width: 767px)'))
|
||||||
|
expect(result.current).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should update when media query changes', () => {
|
||||||
|
let currentMatches = false
|
||||||
|
let changeHandler: ((e: MediaQueryListEvent) => void) | null = null
|
||||||
|
|
||||||
|
matchMediaMock.mockImplementation((query: string) => ({
|
||||||
|
matches: currentMatches,
|
||||||
|
media: query,
|
||||||
|
addEventListener: vi.fn((event: string, handler: (e: MediaQueryListEvent) => void) => {
|
||||||
|
if (event === 'change') {
|
||||||
|
changeHandler = handler
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useMediaQuery('(max-width: 767px)'))
|
||||||
|
expect(result.current).toBe(false)
|
||||||
|
|
||||||
|
// Simulate viewport resize
|
||||||
|
act(() => {
|
||||||
|
currentMatches = true
|
||||||
|
if (changeHandler) {
|
||||||
|
changeHandler({ matches: true } as MediaQueryListEvent)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should cleanup listener on unmount', () => {
|
||||||
|
const removeEventListenerMock = vi.fn()
|
||||||
|
|
||||||
|
matchMediaMock.mockImplementation(() => ({
|
||||||
|
matches: false,
|
||||||
|
media: '(max-width: 767px)',
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: removeEventListenerMock,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { unmount } = renderHook(() => useMediaQuery('(max-width: 767px)'))
|
||||||
|
unmount()
|
||||||
|
|
||||||
|
expect(removeEventListenerMock).toHaveBeenCalledWith('change', expect.any(Function))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useIsMobile', () => {
|
||||||
|
it('should return true when viewport is mobile width', () => {
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn((query: string) => ({
|
||||||
|
matches: query === '(max-width: 767px)',
|
||||||
|
media: query,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useIsMobile())
|
||||||
|
expect(result.current).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false when viewport is not mobile width', () => {
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn(() => ({
|
||||||
|
matches: false,
|
||||||
|
media: '(max-width: 767px)',
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useIsMobile())
|
||||||
|
expect(result.current).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useIsTablet', () => {
|
||||||
|
it('should return true when viewport is tablet width', () => {
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn((query: string) => ({
|
||||||
|
matches: query === '(min-width: 768px) and (max-width: 1023px)',
|
||||||
|
media: query,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useIsTablet())
|
||||||
|
expect(result.current).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useIsDesktop', () => {
|
||||||
|
it('should return true when viewport is desktop width', () => {
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn((query: string) => ({
|
||||||
|
matches: query === '(min-width: 1024px)',
|
||||||
|
media: query,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useIsDesktop())
|
||||||
|
expect(result.current).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
74
frontend/src/hooks/useMediaQuery.ts
Normal file
74
frontend/src/hooks/useMediaQuery.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for responsive breakpoint detection using CSS media queries.
|
||||||
|
* Updates when the viewport is resized across breakpoints.
|
||||||
|
*/
|
||||||
|
export function useMediaQuery(query: string): boolean {
|
||||||
|
const [matches, setMatches] = useState<boolean>(() => {
|
||||||
|
// Check if window is available (SSR safety)
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
return window.matchMedia(query).matches
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
|
||||||
|
const mediaQuery = window.matchMedia(query)
|
||||||
|
|
||||||
|
// Set initial value
|
||||||
|
setMatches(mediaQuery.matches)
|
||||||
|
|
||||||
|
// Create event handler
|
||||||
|
const handleChange = (event: MediaQueryListEvent) => {
|
||||||
|
setMatches(event.matches)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add listener (using modern API with fallback)
|
||||||
|
if (mediaQuery.addEventListener) {
|
||||||
|
mediaQuery.addEventListener('change', handleChange)
|
||||||
|
} else {
|
||||||
|
// Fallback for older browsers
|
||||||
|
mediaQuery.addListener(handleChange)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
return () => {
|
||||||
|
if (mediaQuery.removeEventListener) {
|
||||||
|
mediaQuery.removeEventListener('change', handleChange)
|
||||||
|
} else {
|
||||||
|
mediaQuery.removeListener(handleChange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [query])
|
||||||
|
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
|
||||||
|
// Breakpoints aligned with Tailwind CSS defaults
|
||||||
|
const MOBILE_BREAKPOINT = '(max-width: 767px)'
|
||||||
|
const TABLET_BREAKPOINT = '(min-width: 768px) and (max-width: 1023px)'
|
||||||
|
const DESKTOP_BREAKPOINT = '(min-width: 1024px)'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when viewport width is less than 768px (mobile devices)
|
||||||
|
*/
|
||||||
|
export function useIsMobile(): boolean {
|
||||||
|
return useMediaQuery(MOBILE_BREAKPOINT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when viewport width is between 768px and 1023px (tablets)
|
||||||
|
*/
|
||||||
|
export function useIsTablet(): boolean {
|
||||||
|
return useMediaQuery(TABLET_BREAKPOINT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when viewport width is 1024px or greater (desktops)
|
||||||
|
*/
|
||||||
|
export function useIsDesktop(): boolean {
|
||||||
|
return useMediaQuery(DESKTOP_BREAKPOINT)
|
||||||
|
}
|
||||||
@@ -78,6 +78,29 @@ export function useDeleteRoom() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function usePermanentDeleteRoom() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (roomId: string) => roomsService.permanentDeleteRoom(roomId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: roomKeys.lists() })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useJoinRoom() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (roomId: string) => roomsService.joinRoom(roomId),
|
||||||
|
onSuccess: (_, roomId) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: roomKeys.detail(roomId) })
|
||||||
|
queryClient.invalidateQueries({ queryKey: roomKeys.lists() })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function useAddMember(roomId: string) {
|
export function useAddMember(roomId: string) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
|||||||
11
frontend/src/hooks/useUsers.ts
Normal file
11
frontend/src/hooks/useUsers.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { usersService } from '../services/users'
|
||||||
|
|
||||||
|
export function useUserSearch(query: string, enabled: boolean = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['users', 'search', query],
|
||||||
|
queryFn: () => usersService.searchUsers(query),
|
||||||
|
enabled: enabled && query.length >= 1,
|
||||||
|
staleTime: 1000 * 60, // 1 minute
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ interface UseWebSocketOptions {
|
|||||||
onMessage?: (message: Message) => void
|
onMessage?: (message: Message) => void
|
||||||
onFileUploaded?: (data: FileUploadedBroadcast) => void
|
onFileUploaded?: (data: FileUploadedBroadcast) => void
|
||||||
onFileDeleted?: (data: FileDeletedBroadcast) => void
|
onFileDeleted?: (data: FileDeletedBroadcast) => void
|
||||||
|
onRoomDeleted?: (roomId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useWebSocket(roomId: string | null, options?: UseWebSocketOptions) {
|
export function useWebSocket(roomId: string | null, options?: UseWebSocketOptions) {
|
||||||
@@ -137,6 +138,8 @@ export function useWebSocket(roomId: string | null, options?: UseWebSocketOption
|
|||||||
addOnlineUser(systemData.user_id || '')
|
addOnlineUser(systemData.user_id || '')
|
||||||
} else if (systemData.event === 'user_left') {
|
} else if (systemData.event === 'user_left') {
|
||||||
removeOnlineUser(systemData.user_id || '')
|
removeOnlineUser(systemData.user_id || '')
|
||||||
|
} else if (systemData.event === 'room_deleted') {
|
||||||
|
options?.onRoomDeleted?.(systemData.room_id || '')
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,56 @@
|
|||||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
|
||||||
|
/* Safe area insets for notched devices (iPhone X+) */
|
||||||
|
--safe-area-inset-top: env(safe-area-inset-top, 0px);
|
||||||
|
--safe-area-inset-right: env(safe-area-inset-right, 0px);
|
||||||
|
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
--safe-area-inset-left: env(safe-area-inset-left, 0px);
|
||||||
|
|
||||||
|
/* Mobile-specific spacing */
|
||||||
|
--mobile-header-height: 56px;
|
||||||
|
--mobile-bottom-toolbar-height: 56px;
|
||||||
|
--touch-target-min: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
/* Prevent pull-to-refresh on mobile */
|
||||||
|
overscroll-behavior-y: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Utility class for safe area padding */
|
||||||
|
.safe-area-bottom {
|
||||||
|
padding-bottom: max(var(--safe-area-inset-bottom), 16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.safe-area-top {
|
||||||
|
padding-top: max(var(--safe-area-inset-top), 0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Touch-friendly minimum size utility */
|
||||||
|
.touch-target {
|
||||||
|
min-width: var(--touch-target-min);
|
||||||
|
min-height: var(--touch-target-min);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prevent text selection on touch for UI elements */
|
||||||
|
.no-select {
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth transitions for layout changes */
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.transition-layout {
|
||||||
|
transition: all 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ describe('Login', () => {
|
|||||||
|
|
||||||
expect(screen.getByText('Task Reporter')).toBeInTheDocument()
|
expect(screen.getByText('Task Reporter')).toBeInTheDocument()
|
||||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
|
expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
|
||||||
expect(screen.getByLabelText(/password/i)).toBeInTheDocument()
|
expect(screen.getByPlaceholderText(/enter your password/i)).toBeInTheDocument()
|
||||||
expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument()
|
expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -48,7 +48,13 @@ describe('Login', () => {
|
|||||||
render(<Login />)
|
render(<Login />)
|
||||||
|
|
||||||
expect(screen.getByLabelText(/email/i)).toHaveValue('')
|
expect(screen.getByLabelText(/email/i)).toHaveValue('')
|
||||||
expect(screen.getByLabelText(/password/i)).toHaveValue('')
|
expect(screen.getByPlaceholderText(/enter your password/i)).toHaveValue('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should have password visibility toggle', () => {
|
||||||
|
render(<Login />)
|
||||||
|
|
||||||
|
expect(screen.getByLabelText(/show password/i)).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -58,7 +64,7 @@ describe('Login', () => {
|
|||||||
render(<Login />)
|
render(<Login />)
|
||||||
|
|
||||||
const emailInput = screen.getByLabelText(/email/i)
|
const emailInput = screen.getByLabelText(/email/i)
|
||||||
const passwordInput = screen.getByLabelText(/password/i)
|
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||||
|
|
||||||
await user.type(emailInput, 'test@example.com')
|
await user.type(emailInput, 'test@example.com')
|
||||||
await user.type(passwordInput, 'password123')
|
await user.type(passwordInput, 'password123')
|
||||||
@@ -67,6 +73,22 @@ describe('Login', () => {
|
|||||||
expect(passwordInput).toHaveValue('password123')
|
expect(passwordInput).toHaveValue('password123')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should toggle password visibility', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<Login />)
|
||||||
|
|
||||||
|
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||||
|
const toggleButton = screen.getByLabelText(/show password/i)
|
||||||
|
|
||||||
|
expect(passwordInput).toHaveAttribute('type', 'password')
|
||||||
|
|
||||||
|
await user.click(toggleButton)
|
||||||
|
expect(passwordInput).toHaveAttribute('type', 'text')
|
||||||
|
|
||||||
|
await user.click(screen.getByLabelText(/hide password/i))
|
||||||
|
expect(passwordInput).toHaveAttribute('type', 'password')
|
||||||
|
})
|
||||||
|
|
||||||
it('should submit form with credentials', async () => {
|
it('should submit form with credentials', async () => {
|
||||||
vi.mocked(authService.login).mockResolvedValue({
|
vi.mocked(authService.login).mockResolvedValue({
|
||||||
token: 'test-token',
|
token: 'test-token',
|
||||||
@@ -77,7 +99,7 @@ describe('Login', () => {
|
|||||||
render(<Login />)
|
render(<Login />)
|
||||||
|
|
||||||
await user.type(screen.getByLabelText(/email/i), 'testuser')
|
await user.type(screen.getByLabelText(/email/i), 'testuser')
|
||||||
await user.type(screen.getByLabelText(/password/i), 'password123')
|
await user.type(screen.getByPlaceholderText(/enter your password/i), 'password123')
|
||||||
await user.click(screen.getByRole('button', { name: /login/i }))
|
await user.click(screen.getByRole('button', { name: /login/i }))
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -98,7 +120,7 @@ describe('Login', () => {
|
|||||||
render(<Login />)
|
render(<Login />)
|
||||||
|
|
||||||
await user.type(screen.getByLabelText(/email/i), 'testuser')
|
await user.type(screen.getByLabelText(/email/i), 'testuser')
|
||||||
await user.type(screen.getByLabelText(/password/i), 'password123')
|
await user.type(screen.getByPlaceholderText(/enter your password/i), 'password123')
|
||||||
await user.click(screen.getByRole('button', { name: /login/i }))
|
await user.click(screen.getByRole('button', { name: /login/i }))
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -116,7 +138,7 @@ describe('Login', () => {
|
|||||||
render(<Login />)
|
render(<Login />)
|
||||||
|
|
||||||
await user.type(screen.getByLabelText(/email/i), 'testuser')
|
await user.type(screen.getByLabelText(/email/i), 'testuser')
|
||||||
await user.type(screen.getByLabelText(/password/i), 'password123')
|
await user.type(screen.getByPlaceholderText(/enter your password/i), 'password123')
|
||||||
await user.click(screen.getByRole('button', { name: /login/i }))
|
await user.click(screen.getByRole('button', { name: /login/i }))
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -135,7 +157,7 @@ describe('Login', () => {
|
|||||||
render(<Login />)
|
render(<Login />)
|
||||||
|
|
||||||
await user.type(screen.getByLabelText(/email/i), 'testuser')
|
await user.type(screen.getByLabelText(/email/i), 'testuser')
|
||||||
await user.type(screen.getByLabelText(/password/i), 'wrongpassword')
|
await user.type(screen.getByPlaceholderText(/enter your password/i), 'wrongpassword')
|
||||||
await user.click(screen.getByRole('button', { name: /login/i }))
|
await user.click(screen.getByRole('button', { name: /login/i }))
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -155,7 +177,7 @@ describe('Login', () => {
|
|||||||
render(<Login />)
|
render(<Login />)
|
||||||
|
|
||||||
await user.type(screen.getByLabelText(/email/i), 'testuser')
|
await user.type(screen.getByLabelText(/email/i), 'testuser')
|
||||||
await user.type(screen.getByLabelText(/password/i), 'password123')
|
await user.type(screen.getByPlaceholderText(/enter your password/i), 'password123')
|
||||||
await user.click(screen.getByRole('button', { name: /login/i }))
|
await user.click(screen.getByRole('button', { name: /login/i }))
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -172,11 +194,11 @@ describe('Login', () => {
|
|||||||
render(<Login />)
|
render(<Login />)
|
||||||
|
|
||||||
await user.type(screen.getByLabelText(/email/i), 'testuser')
|
await user.type(screen.getByLabelText(/email/i), 'testuser')
|
||||||
await user.type(screen.getByLabelText(/password/i), 'password123')
|
await user.type(screen.getByPlaceholderText(/enter your password/i), 'password123')
|
||||||
await user.click(screen.getByRole('button', { name: /login/i }))
|
await user.click(screen.getByRole('button', { name: /login/i }))
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole('button')).toBeDisabled()
|
expect(screen.getByRole('button', { name: /logging in/i })).toBeDisabled()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export default function Login() {
|
|||||||
|
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
|
||||||
const loginMutation = useMutation({
|
const loginMutation = useMutation({
|
||||||
mutationFn: () => authService.login({ username, password }),
|
mutationFn: () => authService.login({ username, password }),
|
||||||
@@ -68,16 +69,61 @@ export default function Login() {
|
|||||||
>
|
>
|
||||||
Password
|
Password
|
||||||
</label>
|
</label>
|
||||||
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type={showPassword ? 'text' : 'password'}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
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"
|
className="w-full px-4 py-2 pr-12 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"
|
placeholder="Enter your password"
|
||||||
required
|
required
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-500 hover:text-gray-700"
|
||||||
|
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
className="w-5 h-5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
className="w-5 h-5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error Message */}
|
{/* Error Message */}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import { useParams, Link } from 'react-router'
|
import { useParams, Link, useNavigate } from 'react-router'
|
||||||
import {
|
import {
|
||||||
useRoom,
|
useRoom,
|
||||||
useRoomPermissions,
|
useRoomPermissions,
|
||||||
@@ -7,15 +7,19 @@ import {
|
|||||||
useAddMember,
|
useAddMember,
|
||||||
useUpdateMemberRole,
|
useUpdateMemberRole,
|
||||||
useRemoveMember,
|
useRemoveMember,
|
||||||
|
usePermanentDeleteRoom,
|
||||||
} from '../hooks/useRooms'
|
} from '../hooks/useRooms'
|
||||||
import { useMessages } from '../hooks/useMessages'
|
import { useMessages } from '../hooks/useMessages'
|
||||||
import { useWebSocket } from '../hooks/useWebSocket'
|
import { useWebSocket } from '../hooks/useWebSocket'
|
||||||
import { useFiles, useUploadFile, useDeleteFile } from '../hooks/useFiles'
|
import { useFiles, useUploadFile, useDeleteFile } from '../hooks/useFiles'
|
||||||
import { useGenerateReport, useDownloadReport } from '../hooks/useReports'
|
import { useGenerateReport, useDownloadReport } from '../hooks/useReports'
|
||||||
|
import { useUserSearch } from '../hooks/useUsers'
|
||||||
|
import { useIsMobile } from '../hooks/useMediaQuery'
|
||||||
import { filesService } from '../services/files'
|
import { filesService } from '../services/files'
|
||||||
import { useChatStore } from '../stores/chatStore'
|
import { useChatStore } from '../stores/chatStore'
|
||||||
import { useAuthStore } from '../stores/authStore'
|
import { useAuthStore, useIsAdmin } from '../stores/authStore'
|
||||||
import { Breadcrumb } from '../components/common'
|
import { Breadcrumb } from '../components/common'
|
||||||
|
import { MobileHeader, BottomToolbar, SlidePanel } from '../components/mobile'
|
||||||
import ReportProgress from '../components/report/ReportProgress'
|
import ReportProgress from '../components/report/ReportProgress'
|
||||||
import type { SeverityLevel, RoomStatus, MemberRole, FileMetadata, ReportStatus } from '../types'
|
import type { SeverityLevel, RoomStatus, MemberRole, FileMetadata, ReportStatus } from '../types'
|
||||||
|
|
||||||
@@ -42,20 +46,36 @@ const QUICK_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉']
|
|||||||
|
|
||||||
export default function RoomDetail() {
|
export default function RoomDetail() {
|
||||||
const { roomId } = useParams<{ roomId: string }>()
|
const { roomId } = useParams<{ roomId: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
const user = useAuthStore((state) => state.user)
|
const user = useAuthStore((state) => state.user)
|
||||||
|
const isAdmin = useIsAdmin()
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
const { data: room, isLoading: roomLoading, error: roomError } = useRoom(roomId || '')
|
const { data: room, isLoading: roomLoading, error: roomError } = useRoom(roomId || '')
|
||||||
const { data: permissions } = useRoomPermissions(roomId || '')
|
const { data: permissions } = useRoomPermissions(roomId || '')
|
||||||
const { data: messagesData, isLoading: messagesLoading } = useMessages(roomId || '', { limit: 50 })
|
const { data: messagesData, isLoading: messagesLoading } = useMessages(roomId || '', { limit: 50 })
|
||||||
|
|
||||||
const { messages, connectionStatus, typingUsers, onlineUsers, setMessages, setCurrentRoom } = useChatStore()
|
const { messages, connectionStatus, typingUsers, onlineUsers, setMessages, setCurrentRoom } = useChatStore()
|
||||||
const { sendTextMessage, sendTyping, editMessage, deleteMessage, addReaction, removeReaction } = useWebSocket(roomId || null)
|
|
||||||
|
// Handle room deleted event from WebSocket
|
||||||
|
const handleRoomDeleted = useCallback((deletedRoomId: string) => {
|
||||||
|
if (deletedRoomId === roomId) {
|
||||||
|
alert('This room has been permanently deleted by an administrator.')
|
||||||
|
navigate('/')
|
||||||
|
}
|
||||||
|
}, [roomId, navigate])
|
||||||
|
|
||||||
|
const { sendTextMessage, sendTyping, editMessage, deleteMessage, addReaction, removeReaction } = useWebSocket(
|
||||||
|
roomId || null,
|
||||||
|
{ onRoomDeleted: handleRoomDeleted }
|
||||||
|
)
|
||||||
|
|
||||||
// Mutations
|
// Mutations
|
||||||
const updateRoom = useUpdateRoom(roomId || '')
|
const updateRoom = useUpdateRoom(roomId || '')
|
||||||
const addMember = useAddMember(roomId || '')
|
const addMember = useAddMember(roomId || '')
|
||||||
const updateMemberRole = useUpdateMemberRole(roomId || '')
|
const updateMemberRole = useUpdateMemberRole(roomId || '')
|
||||||
const removeMember = useRemoveMember(roomId || '')
|
const removeMember = useRemoveMember(roomId || '')
|
||||||
|
const permanentDeleteRoom = usePermanentDeleteRoom()
|
||||||
|
|
||||||
// File hooks
|
// File hooks
|
||||||
const { data: filesData, isLoading: filesLoading } = useFiles(roomId || '')
|
const { data: filesData, isLoading: filesLoading } = useFiles(roomId || '')
|
||||||
@@ -88,8 +108,35 @@ export default function RoomDetail() {
|
|||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const [newMemberUsername, setNewMemberUsername] = useState('')
|
const [newMemberUsername, setNewMemberUsername] = useState('')
|
||||||
const [newMemberRole, setNewMemberRole] = useState<MemberRole>('viewer')
|
const [newMemberRole, setNewMemberRole] = useState<MemberRole>('viewer')
|
||||||
|
const [userSearchQuery, setUserSearchQuery] = useState('')
|
||||||
|
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('')
|
||||||
|
const [showUserDropdown, setShowUserDropdown] = useState(false)
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||||
|
const [deleteConfirmInput, setDeleteConfirmInput] = useState('')
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
const typingTimeoutRef = useRef<number | null>(null)
|
const typingTimeoutRef = useRef<number | null>(null)
|
||||||
|
const searchTimeoutRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
// User search with debounce
|
||||||
|
const { data: userSearchResults, isLoading: userSearchLoading } = useUserSearch(
|
||||||
|
debouncedSearchQuery,
|
||||||
|
showAddMember && debouncedSearchQuery.length >= 1
|
||||||
|
)
|
||||||
|
|
||||||
|
// Debounce user search
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchTimeoutRef.current) {
|
||||||
|
clearTimeout(searchTimeoutRef.current)
|
||||||
|
}
|
||||||
|
searchTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
setDebouncedSearchQuery(userSearchQuery)
|
||||||
|
}, 300)
|
||||||
|
return () => {
|
||||||
|
if (searchTimeoutRef.current) {
|
||||||
|
clearTimeout(searchTimeoutRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [userSearchQuery])
|
||||||
|
|
||||||
// Initialize room
|
// Initialize room
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -188,6 +235,8 @@ export default function RoomDetail() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setNewMemberUsername('')
|
setNewMemberUsername('')
|
||||||
setNewMemberRole('viewer')
|
setNewMemberRole('viewer')
|
||||||
|
setUserSearchQuery('')
|
||||||
|
setShowUserDropdown(false)
|
||||||
setShowAddMember(false)
|
setShowAddMember(false)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -204,6 +253,17 @@ export default function RoomDetail() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Permanent delete room (admin only)
|
||||||
|
const handlePermanentDelete = () => {
|
||||||
|
if (!roomId || !room) return
|
||||||
|
|
||||||
|
permanentDeleteRoom.mutate(roomId, {
|
||||||
|
onSuccess: () => {
|
||||||
|
navigate('/')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// File handlers
|
// File handlers
|
||||||
const handleFileUpload = useCallback(
|
const handleFileUpload = useCallback(
|
||||||
(files: FileList | null) => {
|
(files: FileList | null) => {
|
||||||
@@ -341,9 +401,33 @@ export default function RoomDetail() {
|
|||||||
const typingUsersArray = Array.from(typingUsers).filter((u) => u !== user?.username)
|
const typingUsersArray = Array.from(typingUsers).filter((u) => u !== user?.username)
|
||||||
const onlineUsersArray = Array.from(onlineUsers)
|
const onlineUsersArray = Array.from(onlineUsers)
|
||||||
|
|
||||||
|
// Toggle handlers for mobile
|
||||||
|
const handleFilesToggle = () => {
|
||||||
|
setShowFiles(!showFiles)
|
||||||
|
if (!isMobile) setShowMembers(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMembersToggle = () => {
|
||||||
|
setShowMembers(!showMembers)
|
||||||
|
if (!isMobile) setShowFiles(false)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex flex-col">
|
<div className="min-h-screen bg-gray-50 flex flex-col">
|
||||||
{/* Header */}
|
{/* Header - Mobile vs Desktop */}
|
||||||
|
{isMobile ? (
|
||||||
|
<MobileHeader
|
||||||
|
title={room.title}
|
||||||
|
status={room.status}
|
||||||
|
connectionStatus={connectionStatus}
|
||||||
|
canUpdateStatus={permissions?.can_update_status}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
isGeneratingReport={generateReport.isPending}
|
||||||
|
onGenerateReport={handleGenerateReport}
|
||||||
|
onStatusChange={handleStatusChange}
|
||||||
|
onPermanentDelete={() => setShowDeleteConfirm(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<header className="bg-white shadow-sm flex-shrink-0">
|
<header className="bg-white shadow-sm flex-shrink-0">
|
||||||
<div className="max-w-7xl mx-auto px-4 py-3">
|
<div className="max-w-7xl mx-auto px-4 py-3">
|
||||||
{/* Breadcrumb */}
|
{/* Breadcrumb */}
|
||||||
@@ -423,12 +507,20 @@ export default function RoomDetail() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Permanent Delete (Admin only) */}
|
||||||
|
{isAdmin && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
|
className="px-2 py-1 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200"
|
||||||
|
title="Permanently delete this room (Admin only)"
|
||||||
|
>
|
||||||
|
Delete Permanently
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Files Toggle */}
|
{/* Files Toggle */}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={handleFilesToggle}
|
||||||
setShowFiles(!showFiles)
|
|
||||||
setShowMembers(false)
|
|
||||||
}}
|
|
||||||
className={`flex items-center gap-1 ${showFiles ? 'text-blue-600' : 'text-gray-600 hover:text-gray-800'}`}
|
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">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -444,10 +536,7 @@ export default function RoomDetail() {
|
|||||||
|
|
||||||
{/* Members Toggle */}
|
{/* Members Toggle */}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={handleMembersToggle}
|
||||||
setShowMembers(!showMembers)
|
|
||||||
setShowFiles(false)
|
|
||||||
}}
|
|
||||||
className={`flex items-center gap-1 ${showMembers ? 'text-blue-600' : 'text-gray-600 hover:text-gray-800'}`}
|
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">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -464,6 +553,7 @@ export default function RoomDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<div className="flex-1 flex overflow-hidden">
|
<div className="flex-1 flex overflow-hidden">
|
||||||
@@ -639,29 +729,44 @@ export default function RoomDetail() {
|
|||||||
|
|
||||||
{/* Message Input */}
|
{/* Message Input */}
|
||||||
{permissions?.can_write && (
|
{permissions?.can_write && (
|
||||||
<form onSubmit={handleSendMessage} className="p-4 bg-white border-t">
|
<form onSubmit={handleSendMessage} className={`p-4 bg-white border-t ${isMobile ? 'pb-2' : ''}`}>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={messageInput}
|
value={messageInput}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
placeholder="Type a message..."
|
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"
|
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'
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!messageInput.trim()}
|
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"
|
className={`bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||||
|
isMobile ? 'px-5 py-3 touch-target' : 'px-4 py-2'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
Send
|
Send
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Bottom Toolbar - Mobile Only */}
|
||||||
|
{isMobile && (
|
||||||
|
<BottomToolbar
|
||||||
|
showFiles={showFiles}
|
||||||
|
showMembers={showMembers}
|
||||||
|
memberCount={room.member_count || 0}
|
||||||
|
onFilesToggle={handleFilesToggle}
|
||||||
|
onMembersToggle={handleMembersToggle}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Members Sidebar */}
|
{/* Members Sidebar - Desktop Only */}
|
||||||
{showMembers && (
|
{!isMobile && showMembers && (
|
||||||
<div className="w-72 bg-white border-l flex-shrink-0 overflow-y-auto">
|
<div className="w-72 bg-white border-l flex-shrink-0 overflow-y-auto">
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
@@ -679,13 +784,53 @@ export default function RoomDetail() {
|
|||||||
{/* Add Member Form */}
|
{/* Add Member Form */}
|
||||||
{showAddMember && (
|
{showAddMember && (
|
||||||
<form onSubmit={handleAddMember} className="mb-4 p-3 bg-gray-50 rounded-lg">
|
<form onSubmit={handleAddMember} className="mb-4 p-3 bg-gray-50 rounded-lg">
|
||||||
|
<div className="relative mb-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={newMemberUsername}
|
value={userSearchQuery}
|
||||||
onChange={(e) => setNewMemberUsername(e.target.value)}
|
onChange={(e) => {
|
||||||
placeholder="Username"
|
setUserSearchQuery(e.target.value)
|
||||||
className="w-full px-3 py-1.5 text-sm border border-gray-300 rounded mb-2"
|
setShowUserDropdown(true)
|
||||||
|
if (e.target.value !== newMemberUsername) {
|
||||||
|
setNewMemberUsername('')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onFocus={() => setShowUserDropdown(true)}
|
||||||
|
placeholder="Search user by name or email..."
|
||||||
|
className="w-full px-3 py-1.5 text-sm border border-gray-300 rounded"
|
||||||
/>
|
/>
|
||||||
|
{/* User Search Dropdown */}
|
||||||
|
{showUserDropdown && userSearchQuery.length >= 1 && (
|
||||||
|
<div className="absolute z-10 w-full mt-1 bg-white border border-gray-200 rounded-lg shadow-lg max-h-48 overflow-y-auto">
|
||||||
|
{userSearchLoading ? (
|
||||||
|
<div className="px-3 py-2 text-sm text-gray-500">Searching...</div>
|
||||||
|
) : userSearchResults && userSearchResults.length > 0 ? (
|
||||||
|
userSearchResults.map((result) => (
|
||||||
|
<button
|
||||||
|
key={result.user_id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setNewMemberUsername(result.user_id)
|
||||||
|
setUserSearchQuery(result.display_name || result.user_id)
|
||||||
|
setShowUserDropdown(false)
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 text-left hover:bg-gray-100 text-sm"
|
||||||
|
>
|
||||||
|
<div className="font-medium text-gray-900">{result.display_name}</div>
|
||||||
|
<div className="text-xs text-gray-500">{result.user_id}</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="px-3 py-2 text-sm text-gray-500">No users found</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{newMemberUsername && (
|
||||||
|
<div className="text-xs text-green-600 mb-2">
|
||||||
|
Selected: {newMemberUsername}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<select
|
<select
|
||||||
value={newMemberRole}
|
value={newMemberRole}
|
||||||
@@ -697,7 +842,7 @@ export default function RoomDetail() {
|
|||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={addMember.isPending}
|
disabled={addMember.isPending || !newMemberUsername}
|
||||||
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
|
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Add
|
Add
|
||||||
@@ -711,7 +856,12 @@ export default function RoomDetail() {
|
|||||||
|
|
||||||
{/* Member List */}
|
{/* Member List */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{room.members?.map((member) => (
|
{room.members?.map((member) => {
|
||||||
|
const isOwner = permissions?.role === 'owner'
|
||||||
|
const isEditor = permissions?.role === 'editor'
|
||||||
|
const canUpgradeToEditor = isEditor && member.role === 'viewer'
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={member.user_id}
|
key={member.user_id}
|
||||||
className="flex items-center justify-between py-2 px-2 hover:bg-gray-50 rounded"
|
className="flex items-center justify-between py-2 px-2 hover:bg-gray-50 rounded"
|
||||||
@@ -725,8 +875,8 @@ export default function RoomDetail() {
|
|||||||
<span className="text-sm text-gray-900 truncate">{member.user_id}</span>
|
<span className="text-sm text-gray-900 truncate">{member.user_id}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
{/* Role selector (Owner can change roles except their own) */}
|
{/* Owner: Full role selector */}
|
||||||
{permissions?.role === 'owner' && member.role !== 'owner' ? (
|
{isOwner && member.role !== 'owner' ? (
|
||||||
<select
|
<select
|
||||||
value={member.role}
|
value={member.role}
|
||||||
onChange={(e) => handleRoleChange(member.user_id, e.target.value as MemberRole)}
|
onChange={(e) => handleRoleChange(member.user_id, e.target.value as MemberRole)}
|
||||||
@@ -735,11 +885,20 @@ export default function RoomDetail() {
|
|||||||
<option value="viewer">Viewer</option>
|
<option value="viewer">Viewer</option>
|
||||||
<option value="editor">Editor</option>
|
<option value="editor">Editor</option>
|
||||||
</select>
|
</select>
|
||||||
|
) : canUpgradeToEditor ? (
|
||||||
|
/* Editor: Can only upgrade viewer to editor */
|
||||||
|
<button
|
||||||
|
onClick={() => handleRoleChange(member.user_id, 'editor')}
|
||||||
|
className="text-xs px-2 py-0.5 bg-green-100 text-green-700 rounded hover:bg-green-200"
|
||||||
|
title="Upgrade to Editor"
|
||||||
|
>
|
||||||
|
Upgrade
|
||||||
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-gray-500 px-1">{roleLabels[member.role]}</span>
|
<span className="text-xs text-gray-500 px-1">{roleLabels[member.role]}</span>
|
||||||
)}
|
)}
|
||||||
{/* Remove button (Owner/Editor can remove, but not the owner) */}
|
{/* Remove button: Only owner can remove, editors cannot */}
|
||||||
{permissions?.can_manage_members &&
|
{isOwner &&
|
||||||
member.role !== 'owner' &&
|
member.role !== 'owner' &&
|
||||||
member.user_id !== user?.username && (
|
member.user_id !== user?.username && (
|
||||||
<button
|
<button
|
||||||
@@ -754,14 +913,15 @@ export default function RoomDetail() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Files Sidebar */}
|
{/* Files Sidebar - Desktop Only */}
|
||||||
{showFiles && (
|
{!isMobile && showFiles && (
|
||||||
<div className="w-80 bg-white border-l flex-shrink-0 overflow-y-auto">
|
<div className="w-80 bg-white border-l flex-shrink-0 overflow-y-auto">
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<h3 className="font-semibold text-gray-900 mb-4">Files</h3>
|
<h3 className="font-semibold text-gray-900 mb-4">Files</h3>
|
||||||
@@ -894,6 +1054,276 @@ export default function RoomDetail() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Members SlidePanel */}
|
||||||
|
{isMobile && (
|
||||||
|
<SlidePanel
|
||||||
|
isOpen={showMembers}
|
||||||
|
onClose={() => setShowMembers(false)}
|
||||||
|
title="Members"
|
||||||
|
>
|
||||||
|
<div className="p-4">
|
||||||
|
{permissions?.can_manage_members && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddMember(!showAddMember)}
|
||||||
|
className="w-full mb-4 px-4 py-3 text-blue-600 bg-blue-50 rounded-lg hover:bg-blue-100 touch-target"
|
||||||
|
>
|
||||||
|
+ Add Member
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add Member Form */}
|
||||||
|
{showAddMember && (
|
||||||
|
<form onSubmit={handleAddMember} className="mb-4 p-4 bg-gray-50 rounded-lg">
|
||||||
|
<div className="relative mb-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={userSearchQuery}
|
||||||
|
onChange={(e) => {
|
||||||
|
setUserSearchQuery(e.target.value)
|
||||||
|
setShowUserDropdown(true)
|
||||||
|
if (e.target.value !== newMemberUsername) {
|
||||||
|
setNewMemberUsername('')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onFocus={() => setShowUserDropdown(true)}
|
||||||
|
placeholder="Search user by name or email..."
|
||||||
|
className="w-full px-4 py-3 text-base border border-gray-300 rounded-lg"
|
||||||
|
/>
|
||||||
|
{showUserDropdown && userSearchQuery.length >= 1 && (
|
||||||
|
<div className="absolute z-10 w-full mt-1 bg-white border border-gray-200 rounded-lg shadow-lg max-h-48 overflow-y-auto">
|
||||||
|
{userSearchLoading ? (
|
||||||
|
<div className="px-4 py-3 text-gray-500">Searching...</div>
|
||||||
|
) : userSearchResults && userSearchResults.length > 0 ? (
|
||||||
|
userSearchResults.map((result) => (
|
||||||
|
<button
|
||||||
|
key={result.user_id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setNewMemberUsername(result.user_id)
|
||||||
|
setUserSearchQuery(result.display_name || result.user_id)
|
||||||
|
setShowUserDropdown(false)
|
||||||
|
}}
|
||||||
|
className="w-full px-4 py-3 text-left hover:bg-gray-100 touch-target"
|
||||||
|
>
|
||||||
|
<div className="font-medium text-gray-900">{result.display_name}</div>
|
||||||
|
<div className="text-sm text-gray-500">{result.user_id}</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="px-4 py-3 text-gray-500">No users found</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{newMemberUsername && (
|
||||||
|
<div className="text-sm text-green-600 mb-3">Selected: {newMemberUsername}</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<select
|
||||||
|
value={newMemberRole}
|
||||||
|
onChange={(e) => setNewMemberRole(e.target.value as MemberRole)}
|
||||||
|
className="flex-1 px-4 py-3 text-base border border-gray-300 rounded-lg"
|
||||||
|
>
|
||||||
|
<option value="viewer">Viewer</option>
|
||||||
|
<option value="editor">Editor</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={addMember.isPending || !newMemberUsername}
|
||||||
|
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 touch-target"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Member List */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{room.members?.map((member) => {
|
||||||
|
const isOwner = permissions?.role === 'owner'
|
||||||
|
const isEditor = permissions?.role === 'editor'
|
||||||
|
const canUpgradeToEditor = isEditor && member.role === 'viewer'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={member.user_id}
|
||||||
|
className="flex items-center justify-between py-3 px-3 hover:bg-gray-50 rounded-lg touch-target"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<div
|
||||||
|
className={`w-3 h-3 rounded-full flex-shrink-0 ${
|
||||||
|
onlineUsersArray.includes(member.user_id) ? 'bg-green-500' : 'bg-gray-300'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="text-base text-gray-900 truncate">{member.user_id}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{isOwner && member.role !== 'owner' ? (
|
||||||
|
<select
|
||||||
|
value={member.role}
|
||||||
|
onChange={(e) => handleRoleChange(member.user_id, e.target.value as MemberRole)}
|
||||||
|
className="text-sm px-2 py-1 border border-gray-200 rounded"
|
||||||
|
>
|
||||||
|
<option value="viewer">Viewer</option>
|
||||||
|
<option value="editor">Editor</option>
|
||||||
|
</select>
|
||||||
|
) : canUpgradeToEditor ? (
|
||||||
|
<button
|
||||||
|
onClick={() => handleRoleChange(member.user_id, 'editor')}
|
||||||
|
className="text-sm px-3 py-1 bg-green-100 text-green-700 rounded hover:bg-green-200"
|
||||||
|
>
|
||||||
|
Upgrade
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-gray-500">{roleLabels[member.role]}</span>
|
||||||
|
)}
|
||||||
|
{isOwner && member.role !== 'owner' && member.user_id !== user?.username && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveMember(member.user_id)}
|
||||||
|
className="p-2 text-gray-400 hover:text-red-500 touch-target"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SlidePanel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile Files SlidePanel */}
|
||||||
|
{isMobile && (
|
||||||
|
<SlidePanel
|
||||||
|
isOpen={showFiles}
|
||||||
|
onClose={() => setShowFiles(false)}
|
||||||
|
title="Files"
|
||||||
|
>
|
||||||
|
<div className="p-4">
|
||||||
|
{/* Upload Area */}
|
||||||
|
{permissions?.can_write && (
|
||||||
|
<div
|
||||||
|
className={`mb-4 border-2 border-dashed rounded-lg p-6 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-base 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-sm text-gray-500 mt-1">{uploadProgress}%</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg className="w-10 h-10 mx-auto text-gray-400 mb-3" 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>
|
||||||
|
<button
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 touch-target"
|
||||||
|
>
|
||||||
|
Choose File
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* File List */}
|
||||||
|
{filesLoading ? (
|
||||||
|
<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 ? (
|
||||||
|
<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) => (
|
||||||
|
<div
|
||||||
|
key={file.file_id}
|
||||||
|
className="flex items-center gap-3 p-3 hover:bg-gray-50 rounded-lg"
|
||||||
|
>
|
||||||
|
{/* Thumbnail or Icon */}
|
||||||
|
<div className="w-12 h-12 flex-shrink-0 rounded bg-gray-100 flex items-center justify-center overflow-hidden">
|
||||||
|
{filesService.isImage(file.mime_type) ? (
|
||||||
|
<svg className="w-6 h-6 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-6 h-6 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-base text-gray-900 truncate">{file.filename}</p>
|
||||||
|
<p className="text-sm text-gray-500">{filesService.formatFileSize(file.file_size)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{filesService.isImage(file.mime_type) && (
|
||||||
|
<button
|
||||||
|
onClick={() => setPreviewFile(file)}
|
||||||
|
className="p-2 text-gray-400 hover:text-blue-500 touch-target"
|
||||||
|
title="Preview"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" 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>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => handleDownloadFile(file)}
|
||||||
|
className="p-2 text-gray-400 hover:text-blue-500 touch-target"
|
||||||
|
title="Download"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" 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>
|
||||||
|
{(file.uploader_id === user?.username || permissions?.can_delete) && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteFile(file.file_id)}
|
||||||
|
className="p-2 text-gray-400 hover:text-red-500 touch-target"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" 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>
|
||||||
|
</SlidePanel>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Image Preview Modal */}
|
{/* Image Preview Modal */}
|
||||||
{previewFile && (
|
{previewFile && (
|
||||||
<div
|
<div
|
||||||
@@ -941,6 +1371,74 @@ export default function RoomDetail() {
|
|||||||
reportId={reportProgress.reportId}
|
reportId={reportProgress.reportId}
|
||||||
onDownload={handleDownloadReport}
|
onDownload={handleDownloadReport}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Permanent Delete Confirmation Modal (Admin only) */}
|
||||||
|
{showDeleteConfirm && (
|
||||||
|
<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 p-6">
|
||||||
|
<h2 className="text-xl font-semibold text-red-600 mb-4">
|
||||||
|
Permanently Delete Room
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||||
|
<p className="text-sm text-red-800 font-medium mb-2">
|
||||||
|
This action cannot be undone!
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-red-700">
|
||||||
|
The following will be permanently deleted:
|
||||||
|
</p>
|
||||||
|
<ul className="text-sm text-red-700 list-disc list-inside mt-2">
|
||||||
|
<li>All members and permissions</li>
|
||||||
|
<li>All messages and reactions</li>
|
||||||
|
<li>All uploaded files</li>
|
||||||
|
<li>All generated reports</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Type <span className="font-bold">"{room?.title}"</span> to confirm:
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={deleteConfirmInput}
|
||||||
|
onChange={(e) => setDeleteConfirmInput(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500 outline-none"
|
||||||
|
placeholder="Enter room title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{permanentDeleteRoom.isError && (
|
||||||
|
<div className="bg-red-50 text-red-600 px-3 py-2 rounded-lg text-sm">
|
||||||
|
Failed to delete room. Please try again.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowDeleteConfirm(false)
|
||||||
|
setDeleteConfirmInput('')
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 text-gray-600 hover:text-gray-800"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handlePermanentDelete}
|
||||||
|
disabled={deleteConfirmInput !== room?.title || permanentDeleteRoom.isPending}
|
||||||
|
className="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{permanentDeleteRoom.isPending ? 'Deleting...' : 'Delete Permanently'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Link } from 'react-router'
|
import { Link, useNavigate } from 'react-router'
|
||||||
import { useRooms, useCreateRoom, useRoomTemplates } from '../hooks/useRooms'
|
import { useRooms, useCreateRoom, useRoomTemplates, useJoinRoom } from '../hooks/useRooms'
|
||||||
import { useAuthStore } from '../stores/authStore'
|
import { useAuthStore, useIsAdmin } from '../stores/authStore'
|
||||||
|
import { useIsMobile } from '../hooks/useMediaQuery'
|
||||||
import { Breadcrumb } from '../components/common'
|
import { Breadcrumb } from '../components/common'
|
||||||
import type { RoomStatus, IncidentType, SeverityLevel, CreateRoomRequest } from '../types'
|
import type { RoomStatus, IncidentType, SeverityLevel, CreateRoomRequest, Room } from '../types'
|
||||||
|
|
||||||
const statusColors: Record<RoomStatus, string> = {
|
const statusColors: Record<RoomStatus, string> = {
|
||||||
active: 'bg-green-100 text-green-800',
|
active: 'bg-green-100 text-green-800',
|
||||||
@@ -28,13 +29,19 @@ const incidentTypeLabels: Record<IncidentType, string> = {
|
|||||||
const ITEMS_PER_PAGE = 12
|
const ITEMS_PER_PAGE = 12
|
||||||
|
|
||||||
export default function RoomList() {
|
export default function RoomList() {
|
||||||
|
const navigate = useNavigate()
|
||||||
const user = useAuthStore((state) => state.user)
|
const user = useAuthStore((state) => state.user)
|
||||||
const clearAuth = useAuthStore((state) => state.clearAuth)
|
const clearAuth = useAuthStore((state) => state.clearAuth)
|
||||||
|
const isAdmin = useIsAdmin()
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
const [statusFilter, setStatusFilter] = useState<RoomStatus | ''>('')
|
const [statusFilter, setStatusFilter] = useState<RoomStatus | ''>('active')
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
|
const [myRoomsOnly, setMyRoomsOnly] = useState(false)
|
||||||
|
|
||||||
|
const joinRoom = useJoinRoom()
|
||||||
|
|
||||||
// Reset page when filters change
|
// Reset page when filters change
|
||||||
const handleStatusChange = (status: RoomStatus | '') => {
|
const handleStatusChange = (status: RoomStatus | '') => {
|
||||||
@@ -47,9 +54,25 @@ export default function RoomList() {
|
|||||||
setPage(1)
|
setPage(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleMyRoomsToggle = () => {
|
||||||
|
setMyRoomsOnly(!myRoomsOnly)
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleJoinRoom = (e: React.MouseEvent, roomId: string) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
joinRoom.mutate(roomId, {
|
||||||
|
onSuccess: () => {
|
||||||
|
navigate(`/rooms/${roomId}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const { data, isLoading, error } = useRooms({
|
const { data, isLoading, error } = useRooms({
|
||||||
status: statusFilter || undefined,
|
status: statusFilter || undefined,
|
||||||
search: search || undefined,
|
search: search || undefined,
|
||||||
|
my_rooms: myRoomsOnly || undefined,
|
||||||
limit: ITEMS_PER_PAGE,
|
limit: ITEMS_PER_PAGE,
|
||||||
offset: (page - 1) * ITEMS_PER_PAGE,
|
offset: (page - 1) * ITEMS_PER_PAGE,
|
||||||
})
|
})
|
||||||
@@ -63,61 +86,91 @@ export default function RoomList() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="bg-white shadow-sm">
|
<header className="bg-white shadow-sm safe-area-top">
|
||||||
<div className="max-w-7xl mx-auto px-4 py-4 flex justify-between items-center">
|
<div className="max-w-7xl mx-auto px-4 py-3 sm:py-4 flex justify-between items-center">
|
||||||
<h1 className="text-xl font-bold text-gray-900">Task Reporter</h1>
|
<h1 className={`font-bold text-gray-900 ${isMobile ? 'text-lg' : 'text-xl'}`}>Task Reporter</h1>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-2 sm:gap-4">
|
||||||
<span className="text-gray-600">{user?.display_name}</span>
|
{!isMobile && <span className="text-gray-600">{user?.display_name}</span>}
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="text-gray-500 hover:text-gray-700"
|
className={`text-gray-500 hover:text-gray-700 ${isMobile ? 'touch-target p-2' : ''}`}
|
||||||
|
aria-label="Logout"
|
||||||
>
|
>
|
||||||
Logout
|
{isMobile ? (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
'Logout'
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="max-w-7xl mx-auto px-4 py-6">
|
<main className={`max-w-7xl mx-auto px-4 py-4 sm:py-6 ${isMobile ? 'safe-area-bottom' : ''}`}>
|
||||||
{/* Breadcrumb */}
|
{/* Breadcrumb - hide on mobile */}
|
||||||
|
{!isMobile && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<Breadcrumb items={[{ label: 'Home', href: '/' }, { label: 'Rooms' }]} />
|
<Breadcrumb items={[{ label: 'Home', href: '/' }, { label: 'Rooms' }]} />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
<div className="flex flex-col gap-3 mb-4 sm:mb-6">
|
||||||
{/* Search */}
|
{/* Search - Full width on mobile */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search rooms..."
|
placeholder="Search rooms..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => handleSearchChange(e.target.value)}
|
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"
|
className={`w-full border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none ${
|
||||||
|
isMobile ? 'px-4 py-3 text-base' : 'px-4 py-2'
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Filter Row */}
|
||||||
|
<div className="flex flex-wrap gap-2 sm:gap-4">
|
||||||
{/* Status Filter */}
|
{/* Status Filter */}
|
||||||
<select
|
<select
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={(e) => handleStatusChange(e.target.value as RoomStatus | '')}
|
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"
|
className={`border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none flex-1 sm:flex-none ${
|
||||||
|
isMobile ? 'px-3 py-3 text-base min-w-0' : 'px-4 py-2'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<option value="">All Status</option>
|
{isAdmin && <option value="">All Status</option>}
|
||||||
<option value="active">Active</option>
|
<option value="active">Active</option>
|
||||||
<option value="resolved">Resolved</option>
|
<option value="resolved">Resolved</option>
|
||||||
<option value="archived">Archived</option>
|
{isAdmin && <option value="archived">Archived</option>}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
{/* My Rooms Filter */}
|
||||||
|
<button
|
||||||
|
onClick={handleMyRoomsToggle}
|
||||||
|
className={`rounded-lg border transition-colors flex-1 sm:flex-none ${
|
||||||
|
myRoomsOnly
|
||||||
|
? 'bg-blue-600 text-white border-blue-600'
|
||||||
|
: 'border-gray-300 text-gray-700 hover:bg-gray-50'
|
||||||
|
} ${isMobile ? 'px-3 py-3 text-base' : 'px-4 py-2'}`}
|
||||||
|
>
|
||||||
|
{myRoomsOnly ? 'Show All' : 'My Rooms'}
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* New Room Button */}
|
{/* New Room Button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCreateModal(true)}
|
onClick={() => setShowCreateModal(true)}
|
||||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
|
className={`bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex-1 sm:flex-none ${
|
||||||
|
isMobile ? 'px-3 py-3 text-base' : 'px-4 py-2'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
+ New Room
|
+ New Room
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Room List */}
|
{/* Room List */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -141,56 +194,15 @@ export default function RoomList() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<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) => (
|
||||||
<Link
|
<RoomCard
|
||||||
key={room.room_id}
|
key={room.room_id}
|
||||||
to={`/rooms/${room.room_id}`}
|
room={room}
|
||||||
className="bg-white rounded-lg shadow-sm border border-gray-200 p-4 hover:shadow-md transition-shadow"
|
onJoin={handleJoinRoom}
|
||||||
>
|
isJoining={joinRoom.isPending}
|
||||||
{/* Room Header */}
|
isMobile={isMobile}
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
@@ -231,6 +243,97 @@ export default function RoomList() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Room Card Component
|
||||||
|
function RoomCard({
|
||||||
|
room,
|
||||||
|
onJoin,
|
||||||
|
isJoining,
|
||||||
|
isMobile,
|
||||||
|
}: {
|
||||||
|
room: Room
|
||||||
|
onJoin: (e: React.MouseEvent, roomId: string) => void
|
||||||
|
isJoining: boolean
|
||||||
|
isMobile: boolean
|
||||||
|
}) {
|
||||||
|
const isMember = room.is_member || room.current_user_role !== null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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 ${
|
||||||
|
isMobile ? 'p-4' : 'p-4'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Room Header */}
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<h3 className={`font-semibold text-gray-900 truncate flex-1 ${isMobile ? 'text-base' : ''}`}>
|
||||||
|
{room.title}
|
||||||
|
</h3>
|
||||||
|
<div className="flex gap-1 ml-2 flex-shrink-0">
|
||||||
|
{isMember && (
|
||||||
|
<span className={`rounded font-medium bg-blue-100 text-blue-800 ${
|
||||||
|
isMobile ? 'px-2 py-1 text-xs' : 'px-2 py-0.5 text-xs'
|
||||||
|
}`}>
|
||||||
|
Member
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`rounded font-medium ${statusColors[room.status]} ${
|
||||||
|
isMobile ? 'px-2 py-1 text-xs' : 'px-2 py-0.5 text-xs'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{room.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type and Severity */}
|
||||||
|
<div className="flex gap-2 mb-3 flex-wrap">
|
||||||
|
<span className={`text-gray-500 ${isMobile ? 'text-sm' : 'text-xs'}`}>
|
||||||
|
{incidentTypeLabels[room.incident_type]}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`rounded font-medium ${severityColors[room.severity]} ${
|
||||||
|
isMobile ? 'px-2 py-1 text-xs' : 'px-2 py-0.5 text-xs'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{room.severity}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{room.description && (
|
||||||
|
<p className={`text-gray-600 mb-3 line-clamp-2 ${isMobile ? 'text-base' : 'text-sm'}`}>
|
||||||
|
{room.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className={`text-gray-400 ${isMobile ? 'text-sm' : 'text-xs'}`}>
|
||||||
|
{room.member_count} members
|
||||||
|
</span>
|
||||||
|
{!isMember && room.status !== 'archived' ? (
|
||||||
|
<button
|
||||||
|
onClick={(e) => onJoin(e, room.room_id)}
|
||||||
|
disabled={isJoining}
|
||||||
|
className={`bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 ${
|
||||||
|
isMobile ? 'px-4 py-2 text-base touch-target' : 'px-3 py-1 text-sm'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isJoining ? 'Joining...' : 'Join'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className={`text-gray-400 ${isMobile ? 'text-sm' : 'text-xs'}`}>
|
||||||
|
{new Date(room.last_activity_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Create Room Modal Component
|
// Create Room Modal Component
|
||||||
function CreateRoomModal({ onClose }: { onClose: () => void }) {
|
function CreateRoomModal({ onClose }: { onClose: () => void }) {
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
|
|||||||
@@ -12,10 +12,24 @@ const api = axios.create({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Helper to get token from zustand persist storage
|
||||||
|
function getAuthToken(): string | null {
|
||||||
|
try {
|
||||||
|
const authStorage = localStorage.getItem('auth-storage')
|
||||||
|
if (authStorage) {
|
||||||
|
const parsed = JSON.parse(authStorage)
|
||||||
|
return parsed.state?.token || null
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore parse errors
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
// Request interceptor - add auth token
|
// Request interceptor - add auth token
|
||||||
api.interceptors.request.use(
|
api.interceptors.request.use(
|
||||||
(config: InternalAxiosRequestConfig) => {
|
(config: InternalAxiosRequestConfig) => {
|
||||||
const token = localStorage.getItem('token')
|
const token = getAuthToken()
|
||||||
if (token && config.headers) {
|
if (token && config.headers) {
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
}
|
}
|
||||||
@@ -31,9 +45,8 @@ api.interceptors.response.use(
|
|||||||
(response) => response,
|
(response) => response,
|
||||||
(error: AxiosError) => {
|
(error: AxiosError) => {
|
||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
// Token expired or invalid - clear storage and redirect to login
|
// Token expired or invalid - clear zustand auth storage and redirect to login
|
||||||
localStorage.removeItem('token')
|
localStorage.removeItem('auth-storage')
|
||||||
localStorage.removeItem('user')
|
|
||||||
|
|
||||||
// Only redirect if not already on login page
|
// Only redirect if not already on login page
|
||||||
if (window.location.pathname !== '/login') {
|
if (window.location.pathname !== '/login') {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export interface RoomFilters {
|
|||||||
incident_type?: IncidentType
|
incident_type?: IncidentType
|
||||||
severity?: SeverityLevel
|
severity?: SeverityLevel
|
||||||
search?: string
|
search?: string
|
||||||
all?: boolean
|
my_rooms?: boolean
|
||||||
limit?: number
|
limit?: number
|
||||||
offset?: number
|
offset?: number
|
||||||
}
|
}
|
||||||
@@ -35,7 +35,7 @@ export const roomsService = {
|
|||||||
if (filters.incident_type) params.append('incident_type', filters.incident_type)
|
if (filters.incident_type) params.append('incident_type', filters.incident_type)
|
||||||
if (filters.severity) params.append('severity', filters.severity)
|
if (filters.severity) params.append('severity', filters.severity)
|
||||||
if (filters.search) params.append('search', filters.search)
|
if (filters.search) params.append('search', filters.search)
|
||||||
if (filters.all) params.append('all', 'true')
|
if (filters.my_rooms) params.append('my_rooms', 'true')
|
||||||
if (filters.limit) params.append('limit', filters.limit.toString())
|
if (filters.limit) params.append('limit', filters.limit.toString())
|
||||||
if (filters.offset) params.append('offset', filters.offset.toString())
|
if (filters.offset) params.append('offset', filters.offset.toString())
|
||||||
}
|
}
|
||||||
@@ -75,6 +75,22 @@ export const roomsService = {
|
|||||||
await api.delete(`/rooms/${roomId}`)
|
await api.delete(`/rooms/${roomId}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permanently delete room (admin only)
|
||||||
|
* This is an irreversible operation that deletes all room data.
|
||||||
|
*/
|
||||||
|
async permanentDeleteRoom(roomId: string): Promise<void> {
|
||||||
|
await api.delete(`/rooms/${roomId}/permanent`)
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-join a room as a viewer
|
||||||
|
*/
|
||||||
|
async joinRoom(roomId: string): Promise<RoomMember> {
|
||||||
|
const response = await api.post<RoomMember>(`/rooms/${roomId}/join`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get room members
|
* Get room members
|
||||||
*/
|
*/
|
||||||
|
|||||||
16
frontend/src/services/users.ts
Normal file
16
frontend/src/services/users.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import api from './api'
|
||||||
|
|
||||||
|
export interface UserSearchResult {
|
||||||
|
user_id: string
|
||||||
|
display_name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usersService = {
|
||||||
|
/**
|
||||||
|
* Search users by name or email
|
||||||
|
*/
|
||||||
|
async searchUsers(query: string): Promise<UserSearchResult[]> {
|
||||||
|
const response = await api.get<UserSearchResult[]>(`/users/search?q=${encodeURIComponent(query)}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@ import { create } from 'zustand'
|
|||||||
import { persist } from 'zustand/middleware'
|
import { persist } from 'zustand/middleware'
|
||||||
import type { User } from '../types'
|
import type { User } from '../types'
|
||||||
|
|
||||||
|
// System admin email - must match backend SYSTEM_ADMIN_EMAIL
|
||||||
|
const SYSTEM_ADMIN_EMAIL = 'ymirliu@panjit.com.tw'
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
token: string | null
|
token: string | null
|
||||||
user: User | null
|
user: User | null
|
||||||
@@ -49,3 +52,18 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the current user is a system administrator
|
||||||
|
*/
|
||||||
|
export function isSystemAdmin(username: string | undefined): boolean {
|
||||||
|
return username === SYSTEM_ADMIN_EMAIL
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to check if current user is admin
|
||||||
|
*/
|
||||||
|
export function useIsAdmin(): boolean {
|
||||||
|
const user = useAuthStore((state) => state.user)
|
||||||
|
return isSystemAdmin(user?.username)
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export interface Room {
|
|||||||
member_count: number
|
member_count: number
|
||||||
members?: RoomMember[]
|
members?: RoomMember[]
|
||||||
current_user_role?: MemberRole | null
|
current_user_role?: MemberRole | null
|
||||||
|
is_member?: boolean
|
||||||
is_admin_view?: boolean
|
is_admin_view?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,6 +184,7 @@ export type SystemEventType =
|
|||||||
| 'member_removed'
|
| 'member_removed'
|
||||||
| 'file_uploaded'
|
| 'file_uploaded'
|
||||||
| 'file_deleted'
|
| 'file_deleted'
|
||||||
|
| 'room_deleted'
|
||||||
|
|
||||||
export interface WebSocketMessageIn {
|
export interface WebSocketMessageIn {
|
||||||
type: WebSocketMessageType
|
type: WebSocketMessageType
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export default defineConfig({
|
|||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:8000',
|
target: 'http://localhost:8000',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
ws: true, // Enable WebSocket proxying
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Proposal: Add Open Room Access
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
Modify the room access model to allow all authenticated users to view and self-join rooms, while maintaining role-based permissions for room operations.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
The current room model requires explicit invitation for users to see and join rooms. This creates friction in incident response scenarios where speed is critical. Users should be able to:
|
||||||
|
1. Discover all active incidents without needing an invitation
|
||||||
|
2. Self-join rooms to contribute or observe
|
||||||
|
3. Have their role upgraded by existing members
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### Backend Changes (chat-room spec)
|
||||||
|
1. **Public Room Listing**: All authenticated users can view all rooms (not just their own)
|
||||||
|
2. **Self-Join Mechanism**: New endpoint `POST /api/rooms/{room_id}/join` for self-joining as VIEWER
|
||||||
|
3. **Role Upgrade Permission**: EDITOR role gains permission to upgrade VIEWER → EDITOR
|
||||||
|
|
||||||
|
### Frontend Changes (frontend-core spec)
|
||||||
|
1. **Member Search**: Add user search functionality when inviting/managing members
|
||||||
|
2. **Password Visibility Toggle**: Add show/hide password button on login form
|
||||||
|
3. **Join Room Button**: Display "Join" button for rooms where user is not a member
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
- Creating private/invite-only room types (future enhancement)
|
||||||
|
- Role downgrade by EDITOR (only OWNER can downgrade)
|
||||||
|
- Member removal by EDITOR (only OWNER can remove)
|
||||||
|
|
||||||
|
## Related Specs
|
||||||
|
- `chat-room`: Room membership and access control
|
||||||
|
- `frontend-core`: Login and member management UI
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# chat-room Specification Delta
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: List and Filter Incident Rooms
|
||||||
|
The system SHALL provide endpoints to list incident rooms with filtering capabilities. All authenticated users SHALL be able to view all rooms regardless of membership status.
|
||||||
|
|
||||||
|
#### Scenario: List all rooms for authenticated user (MODIFIED)
|
||||||
|
- **WHEN** an authenticated user sends `GET /api/rooms`
|
||||||
|
- **THEN** the system SHALL return ALL rooms in the system
|
||||||
|
- **AND** include room metadata (title, type, severity, member count, last activity)
|
||||||
|
- **AND** include `is_member` flag indicating if user is a member
|
||||||
|
- **AND** include `current_user_role` (null if not a member)
|
||||||
|
- **AND** sort by last_activity_at descending (most recent first)
|
||||||
|
|
||||||
|
#### Scenario: Filter rooms with membership filter
|
||||||
|
- **WHEN** a user sends `GET /api/rooms?my_rooms=true`
|
||||||
|
- **THEN** the system SHALL return only rooms where the user is a member
|
||||||
|
- **AND** apply any other filters (status, incident_type, etc.)
|
||||||
|
|
||||||
|
### Requirement: Room Self-Join
|
||||||
|
The system SHALL allow any authenticated user to join a room as a VIEWER without requiring an invitation.
|
||||||
|
|
||||||
|
#### Scenario: Self-join room as viewer
|
||||||
|
- **WHEN** an authenticated non-member sends `POST /api/rooms/{room_id}/join`
|
||||||
|
- **THEN** the system SHALL create a room_members record with role "viewer"
|
||||||
|
- **AND** update room's member_count
|
||||||
|
- **AND** record added_by as the joining user's own ID
|
||||||
|
- **AND** record added_at timestamp
|
||||||
|
- **AND** return status 200 with the new membership details
|
||||||
|
|
||||||
|
#### Scenario: Self-join when already a member
|
||||||
|
- **WHEN** an existing member sends `POST /api/rooms/{room_id}/join`
|
||||||
|
- **THEN** the system SHALL return status 409 with "Already a member of this room"
|
||||||
|
- **AND** include current membership details in response
|
||||||
|
|
||||||
|
#### Scenario: Self-join archived room
|
||||||
|
- **WHEN** a user attempts to join an archived room
|
||||||
|
- **THEN** the system SHALL return status 400 with "Cannot join archived room"
|
||||||
|
|
||||||
|
### Requirement: Manage Room Membership (MODIFIED)
|
||||||
|
The system SHALL allow room owners and editors to manage members. Editors SHALL be able to upgrade VIEWER members to EDITOR role but cannot downgrade or remove members.
|
||||||
|
|
||||||
|
#### Scenario: Editor upgrades viewer to editor (NEW)
|
||||||
|
- **WHEN** a room editor sends `PATCH /api/rooms/{room_id}/members/{user_id}` with:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"role": "editor"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **AND** the target user is currently a viewer
|
||||||
|
- **THEN** the system SHALL update the member's role to "editor"
|
||||||
|
- **AND** record the change in audit log
|
||||||
|
- **AND** return status 200 with updated member details
|
||||||
|
|
||||||
|
#### Scenario: Editor attempts to downgrade member
|
||||||
|
- **WHEN** a room editor attempts to change a member's role to a lower role (editor → viewer)
|
||||||
|
- **THEN** the system SHALL return status 403 with "Editors can only upgrade members"
|
||||||
|
|
||||||
|
#### Scenario: Editor attempts to remove member
|
||||||
|
- **WHEN** a room editor attempts to remove a member
|
||||||
|
- **THEN** the system SHALL return status 403 with "Only owner can remove members"
|
||||||
|
|
||||||
|
#### Scenario: Editor attempts to set owner role
|
||||||
|
- **WHEN** a room editor attempts to change a member's role to "owner"
|
||||||
|
- **THEN** the system SHALL return status 403 with "Only owner can transfer ownership"
|
||||||
|
|
||||||
|
### Requirement: Room Access Control (MODIFIED)
|
||||||
|
The system SHALL enforce role-based access control. Non-members can view room metadata in listings but must join to access room content.
|
||||||
|
|
||||||
|
#### Scenario: Non-member views room in list (NEW)
|
||||||
|
- **WHEN** a non-member requests room list via `GET /api/rooms`
|
||||||
|
- **THEN** the system SHALL include all rooms with basic metadata
|
||||||
|
- **AND** set `is_member: false` and `current_user_role: null` for non-member rooms
|
||||||
|
|
||||||
|
#### Scenario: Non-member attempts to access room details
|
||||||
|
- **WHEN** a non-member sends `GET /api/rooms/{room_id}`
|
||||||
|
- **THEN** the system SHALL return status 403 with "Join room to access details"
|
||||||
|
- **AND** include a `join_url` field pointing to the join endpoint
|
||||||
|
|
||||||
|
#### Scenario: Non-member attempts to access room messages
|
||||||
|
- **WHEN** a non-member sends `GET /api/rooms/{room_id}/messages`
|
||||||
|
- **THEN** the system SHALL return status 403 with "Not a member of this room"
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: User Directory Search
|
||||||
|
The system SHALL provide a searchable user directory for member management, sourced from the users table (populated during login).
|
||||||
|
|
||||||
|
#### Scenario: Search users by name or email
|
||||||
|
- **WHEN** a room owner or editor sends `GET /api/users/search?q=john`
|
||||||
|
- **THEN** the system SHALL return users matching the search query
|
||||||
|
- **AND** search both display_name and user_id (email) fields
|
||||||
|
- **AND** return at most 20 results
|
||||||
|
- **AND** include user_id and display_name for each result
|
||||||
|
|
||||||
|
#### Scenario: Search with empty query
|
||||||
|
- **WHEN** a user sends `GET /api/users/search` without query parameter
|
||||||
|
- **THEN** the system SHALL return status 400 with "Search query required"
|
||||||
|
|
||||||
|
#### Scenario: Search returns no results
|
||||||
|
- **WHEN** a search query matches no users
|
||||||
|
- **THEN** the system SHALL return an empty array with status 200
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# frontend-core Specification Delta
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: User Authentication Interface (MODIFIED)
|
||||||
|
The frontend SHALL provide a login interface with password visibility toggle.
|
||||||
|
|
||||||
|
#### Scenario: Password visibility toggle (NEW)
|
||||||
|
- **WHEN** a user is on the login page
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Display password field with masked input by default
|
||||||
|
- Show a toggle button (eye icon) next to the password field
|
||||||
|
- Toggle password visibility when button is clicked
|
||||||
|
- Change icon to indicate current state (eye/eye-slash)
|
||||||
|
|
||||||
|
#### Scenario: Password field default state
|
||||||
|
- **WHEN** the login page loads
|
||||||
|
- **THEN** the password field SHALL be in masked (hidden) state
|
||||||
|
- **AND** the toggle button SHALL show "show password" icon
|
||||||
|
|
||||||
|
### Requirement: Incident Room List (MODIFIED)
|
||||||
|
The frontend SHALL display all rooms with join capability for non-member rooms.
|
||||||
|
|
||||||
|
#### Scenario: Display all rooms including non-member rooms (MODIFIED)
|
||||||
|
- **WHEN** a logged-in user navigates to the room list page
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Fetch all rooms from `GET /api/rooms`
|
||||||
|
- Display rooms as cards with title, status, severity, and timestamp
|
||||||
|
- Show "Member" badge for rooms where user is a member
|
||||||
|
- Show "Join" button for rooms where user is not a member
|
||||||
|
- Order by last activity (most recent first)
|
||||||
|
|
||||||
|
#### Scenario: Filter to show only my rooms
|
||||||
|
- **WHEN** a user toggles "My Rooms Only" filter
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Add `?my_rooms=true` parameter to room list request
|
||||||
|
- Display only rooms where user is a member
|
||||||
|
- Hide the filter when active (or show "Show All" toggle)
|
||||||
|
|
||||||
|
#### Scenario: Join room from list
|
||||||
|
- **WHEN** a user clicks "Join" button on a room card
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Send `POST /api/rooms/{room_id}/join`
|
||||||
|
- Update card to show "Member" badge on success
|
||||||
|
- Navigate to room detail page
|
||||||
|
- Show error message on failure
|
||||||
|
|
||||||
|
### Requirement: Member Management Interface (MODIFIED)
|
||||||
|
The frontend SHALL provide member management with user search functionality.
|
||||||
|
|
||||||
|
#### Scenario: Add member with search (MODIFIED)
|
||||||
|
- **WHEN** an owner or editor opens the add member dialog
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Display a searchable user input field
|
||||||
|
- Query `GET /api/users/search?q={query}` as user types (debounced)
|
||||||
|
- Display matching users in a dropdown list
|
||||||
|
- Allow selection of a user from the list
|
||||||
|
- Show role selection dropdown (default: viewer)
|
||||||
|
- Allow role upgrade to editor for existing viewers
|
||||||
|
|
||||||
|
#### Scenario: Search users while adding member
|
||||||
|
- **WHEN** a user types in the member search field
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Wait 300ms after last keystroke (debounce)
|
||||||
|
- Show loading indicator
|
||||||
|
- Display search results with display_name and email
|
||||||
|
- Allow clicking to select a user
|
||||||
|
|
||||||
|
#### Scenario: No search results
|
||||||
|
- **WHEN** user search returns no results
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Display "No users found" message
|
||||||
|
- Suggest checking the spelling or trying a different search
|
||||||
|
|
||||||
|
#### Scenario: Editor upgrades viewer to editor (NEW)
|
||||||
|
- **WHEN** an editor views a viewer member's options
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Display "Upgrade to Editor" option
|
||||||
|
- Submit role change to `PATCH /api/rooms/{room_id}/members/{user_id}`
|
||||||
|
- Update member display on success
|
||||||
|
- Show error message on failure
|
||||||
|
|
||||||
|
#### Scenario: Editor cannot downgrade or remove (NEW)
|
||||||
|
- **WHEN** an editor views member management options
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Hide "Remove" option for all members
|
||||||
|
- Hide role downgrade options
|
||||||
|
- Only show "Upgrade to Editor" for viewers
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Room Join Interface
|
||||||
|
The frontend SHALL provide UI for non-members to join rooms.
|
||||||
|
|
||||||
|
#### Scenario: View room as non-member
|
||||||
|
- **WHEN** a non-member navigates to a room they found in the list
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Display room basic info (title, status, severity)
|
||||||
|
- Show "Join Room" prominent button
|
||||||
|
- Display message explaining they need to join to view content
|
||||||
|
- Show member count and activity summary
|
||||||
|
|
||||||
|
#### Scenario: Join room from detail page
|
||||||
|
- **WHEN** a non-member clicks "Join Room" button
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Send `POST /api/rooms/{room_id}/join`
|
||||||
|
- Reload room with full content access on success
|
||||||
|
- Display success toast "You have joined the room as Viewer"
|
||||||
|
- Show error message on failure
|
||||||
|
|
||||||
|
#### Scenario: Cannot join archived room
|
||||||
|
- **WHEN** a non-member views an archived room
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Display "This room is archived" message
|
||||||
|
- Hide the "Join Room" button
|
||||||
|
- Show room metadata in read-only mode
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# Tasks: Add Open Room Access
|
||||||
|
|
||||||
|
## Phase 1: Backend - Room Visibility & Self-Join
|
||||||
|
|
||||||
|
### 1.1 Modify room listing to show all rooms
|
||||||
|
- [x] Update `room_service.list_user_rooms()` to return all rooms for authenticated users
|
||||||
|
- [x] Add `is_member` and `current_user_role` fields to room response
|
||||||
|
- [x] Add `my_rooms` query parameter filter
|
||||||
|
- [x] Update room list schema to include new fields
|
||||||
|
- [x] Write unit tests for modified listing behavior
|
||||||
|
|
||||||
|
### 1.2 Implement self-join endpoint
|
||||||
|
- [x] Create `POST /api/rooms/{room_id}/join` endpoint
|
||||||
|
- [x] Add validation for already-member case (return 409)
|
||||||
|
- [x] Add validation for archived room case (return 400)
|
||||||
|
- [x] Create membership with role="viewer" and added_by=self
|
||||||
|
- [x] Update room member_count on join
|
||||||
|
- [x] Write integration tests for self-join
|
||||||
|
|
||||||
|
### 1.3 Modify role change permissions for editors
|
||||||
|
- [x] Update `membership_service.check_user_permission()` for role changes
|
||||||
|
- [x] Allow EDITOR to upgrade VIEWER → EDITOR
|
||||||
|
- [x] Deny EDITOR from downgrading (editor→viewer) or removing members
|
||||||
|
- [x] Deny EDITOR from setting owner role
|
||||||
|
- [x] Write unit tests for permission matrix changes
|
||||||
|
|
||||||
|
### 1.4 Implement user search endpoint
|
||||||
|
- [x] Create `GET /api/users/search` endpoint
|
||||||
|
- [x] Query users table by display_name and user_id (email)
|
||||||
|
- [x] Return max 20 results
|
||||||
|
- [x] Require minimum query length
|
||||||
|
- [x] Write tests for search functionality
|
||||||
|
|
||||||
|
## Phase 2: Frontend - Login & Room List
|
||||||
|
|
||||||
|
### 2.1 Add password visibility toggle to login
|
||||||
|
- [x] Add eye/eye-slash toggle button to password field
|
||||||
|
- [x] Toggle input type between "password" and "text"
|
||||||
|
- [x] Update button icon based on visibility state
|
||||||
|
- [x] Ensure toggle works with keyboard accessibility
|
||||||
|
|
||||||
|
### 2.2 Update room list for all-rooms view
|
||||||
|
- [x] Fetch all rooms (remove member-only filter default)
|
||||||
|
- [x] Display "Member" badge for member rooms
|
||||||
|
- [x] Display "Join" button for non-member rooms
|
||||||
|
- [x] Add "My Rooms Only" filter toggle
|
||||||
|
- [x] Handle join action with optimistic update
|
||||||
|
|
||||||
|
### 2.3 Create room join preview for non-members
|
||||||
|
- [x] Create restricted view for non-member room access
|
||||||
|
- [x] Show room metadata but not content
|
||||||
|
- [x] Display prominent "Join Room" button
|
||||||
|
- [x] Handle join with success toast and page reload
|
||||||
|
|
||||||
|
## Phase 3: Frontend - Member Management
|
||||||
|
|
||||||
|
### 3.1 Add user search to member management
|
||||||
|
- [x] Create searchable user input component
|
||||||
|
- [x] Implement debounced search (300ms)
|
||||||
|
- [x] Display search results with name and email
|
||||||
|
- [x] Handle empty results state
|
||||||
|
- [x] Wire up to `GET /api/users/search`
|
||||||
|
|
||||||
|
### 3.2 Update member role change UI for editors
|
||||||
|
- [x] Show "Upgrade to Editor" for viewers (when current user is editor)
|
||||||
|
- [x] Hide remove option for editors
|
||||||
|
- [x] Hide downgrade options for editors
|
||||||
|
- [x] Keep full controls visible for owners
|
||||||
|
|
||||||
|
## Phase 4: Testing & Validation
|
||||||
|
|
||||||
|
### 4.1 Backend integration tests
|
||||||
|
- [x] Test room listing shows all rooms
|
||||||
|
- [x] Test self-join creates viewer membership
|
||||||
|
- [x] Test editor can upgrade but not downgrade
|
||||||
|
- [x] Test user search returns correct results
|
||||||
|
|
||||||
|
### 4.2 Frontend E2E tests
|
||||||
|
- [x] Test password visibility toggle
|
||||||
|
- [x] Test room list shows join buttons
|
||||||
|
- [x] Test self-join flow
|
||||||
|
- [x] Test member search and add flow
|
||||||
|
- [x] Test editor role limitations
|
||||||
|
|
||||||
|
## Validation Checklist
|
||||||
|
- [x] Run `openspec validate add-open-room-access --strict`
|
||||||
|
- [x] All existing tests pass
|
||||||
|
- [x] New tests cover all scenarios
|
||||||
|
- [x] Manual testing of full user flow
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Proposal: Add Admin Room Management
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Currently, rooms can only be soft-deleted (archived) and non-admin users can still see archived rooms when filtering. This creates two issues:
|
||||||
|
|
||||||
|
1. **No permanent deletion**: Archived rooms remain in the database indefinitely, and there's no way to completely remove sensitive or test data.
|
||||||
|
2. **Archived rooms visible to all**: Non-admin users can view archived rooms by changing the status filter, which may expose historical data that should be hidden from general users.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
### 1. Admin-Only Permanent Room Deletion
|
||||||
|
- Add new endpoint `DELETE /api/rooms/{room_id}/permanent`
|
||||||
|
- Only system administrator (ymirliu@panjit.com.tw) can execute
|
||||||
|
- Cascading hard delete of all related data (members, messages, files, reports)
|
||||||
|
- Clean up MinIO storage for associated files
|
||||||
|
- Broadcast WebSocket disconnect to active connections
|
||||||
|
|
||||||
|
### 2. Hide Archived Rooms from Non-Admin Users
|
||||||
|
- Modify room listing to exclude ARCHIVED status for non-admin users
|
||||||
|
- Even "All Status" filter will not show archived rooms for regular users
|
||||||
|
- Admin users retain full visibility of all room statuses
|
||||||
|
- Remove "Archived" option from frontend status filter for non-admin users
|
||||||
|
|
||||||
|
## Related Specs
|
||||||
|
- `chat-room`: Room access control and deletion
|
||||||
|
- `frontend-core`: Room list filtering UI
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
- Batch deletion of multiple rooms
|
||||||
|
- Scheduled auto-deletion of old archived rooms
|
||||||
|
- Restore deleted rooms from backup
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# chat-room Specification Delta
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Admin Permanent Room Deletion
|
||||||
|
The system SHALL provide system administrators with the ability to permanently delete rooms, including all associated data (members, messages, files, reports). This operation is irreversible and restricted to system administrators only.
|
||||||
|
|
||||||
|
#### Scenario: Admin permanently deletes a room
|
||||||
|
- **WHEN** a system administrator sends `DELETE /api/rooms/{room_id}/permanent`
|
||||||
|
- **THEN** the system SHALL verify the user is ymirliu@panjit.com.tw
|
||||||
|
- **AND** hard delete the room record from incident_rooms table
|
||||||
|
- **AND** cascade delete all room_members records
|
||||||
|
- **AND** cascade delete all messages and related reactions/edit_history
|
||||||
|
- **AND** cascade delete all room_files records
|
||||||
|
- **AND** delete associated files from MinIO storage
|
||||||
|
- **AND** cascade delete all generated_reports records
|
||||||
|
- **AND** delete associated report files from MinIO storage
|
||||||
|
- **AND** broadcast disconnect event to any active WebSocket connections in the room
|
||||||
|
- **AND** return status 200 with `{"message": "Room permanently deleted"}`
|
||||||
|
|
||||||
|
#### Scenario: Non-admin attempts permanent deletion
|
||||||
|
- **WHEN** a non-admin user sends `DELETE /api/rooms/{room_id}/permanent`
|
||||||
|
- **THEN** the system SHALL return status 403 with "Only system administrators can permanently delete rooms"
|
||||||
|
|
||||||
|
#### Scenario: Permanent delete non-existent room
|
||||||
|
- **WHEN** a system administrator sends `DELETE /api/rooms/{room_id}/permanent` for a non-existent room
|
||||||
|
- **THEN** the system SHALL return status 404 with "Room not found"
|
||||||
|
|
||||||
|
### Requirement: Hide Archived Rooms from Non-Admin Users
|
||||||
|
The system SHALL hide rooms with ARCHIVED status from non-admin users in all listing operations, ensuring historical/archived data is only visible to system administrators.
|
||||||
|
|
||||||
|
#### Scenario: Non-admin lists rooms with any filter
|
||||||
|
- **WHEN** a non-admin user sends `GET /api/rooms` with any status filter (including no filter)
|
||||||
|
- **THEN** the system SHALL exclude all rooms with status "archived" from the response
|
||||||
|
- **AND** only return rooms with status "active" or "resolved"
|
||||||
|
|
||||||
|
#### Scenario: Non-admin explicitly requests archived rooms
|
||||||
|
- **WHEN** a non-admin user sends `GET /api/rooms?status=archived`
|
||||||
|
- **THEN** the system SHALL return an empty list
|
||||||
|
- **AND** return total count of 0
|
||||||
|
|
||||||
|
#### Scenario: Admin can view archived rooms
|
||||||
|
- **WHEN** a system administrator sends `GET /api/rooms?status=archived`
|
||||||
|
- **THEN** the system SHALL return all archived rooms
|
||||||
|
- **AND** include full room details
|
||||||
|
|
||||||
|
#### Scenario: Admin views all rooms including archived
|
||||||
|
- **WHEN** a system administrator sends `GET /api/rooms` without status filter
|
||||||
|
- **THEN** the system SHALL return all rooms regardless of status
|
||||||
|
- **AND** include archived rooms in the response
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: List and Filter Incident Rooms
|
||||||
|
The system SHALL provide endpoints to list incident rooms with filtering capabilities by status, incident type, severity, date range, and user membership. The system SHALL automatically exclude rooms with ARCHIVED status from listing results for non-admin users, ensuring archived rooms are only visible to system administrators.
|
||||||
|
|
||||||
|
#### Scenario: List all active rooms for current user
|
||||||
|
- **WHEN** an authenticated user sends `GET /api/rooms?status=active`
|
||||||
|
- **THEN** the system SHALL return all active rooms
|
||||||
|
- **AND** include room metadata (title, type, severity, member count, last activity)
|
||||||
|
- **AND** sort by last_activity_at descending (most recent first)
|
||||||
|
|
||||||
|
#### Scenario: Non-admin user lists rooms without status filter
|
||||||
|
- **WHEN** a non-admin user sends `GET /api/rooms` without status parameter
|
||||||
|
- **THEN** the system SHALL return rooms with status "active" or "resolved" only
|
||||||
|
- **AND** automatically exclude archived rooms from results
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# frontend-core Specification Delta
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Incident Room List
|
||||||
|
The frontend SHALL display a filterable, searchable list of incident rooms accessible to the current user. The frontend SHALL restrict the status filter options to show only "Active" and "Resolved" for non-admin users, and SHALL display all status options including "Archived" only for system administrators.
|
||||||
|
|
||||||
|
#### Scenario: Filter rooms by status (Non-admin)
|
||||||
|
- **WHEN** a non-admin user views the status filter dropdown
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Display only "Active" and "Resolved" options
|
||||||
|
- NOT display "Archived" option
|
||||||
|
- NOT display "All Status" option that would include archived rooms
|
||||||
|
|
||||||
|
#### Scenario: Filter rooms by status (Admin)
|
||||||
|
- **WHEN** a system administrator views the status filter dropdown
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Display all status options: "All Status", "Active", "Resolved", "Archived"
|
||||||
|
- Allow viewing archived rooms
|
||||||
|
|
||||||
|
#### Scenario: Default status filter
|
||||||
|
- **WHEN** a user navigates to the room list page
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Default to "Active" status filter for all users
|
||||||
|
- Fetch only active rooms initially
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Admin Room Deletion Interface
|
||||||
|
The frontend SHALL provide system administrators with the ability to permanently delete rooms through a dedicated UI control.
|
||||||
|
|
||||||
|
#### Scenario: Display delete button for admin
|
||||||
|
- **WHEN** a system administrator views a room detail page
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Display a "Delete Room Permanently" button in room settings/actions
|
||||||
|
- Style the button with warning color (red)
|
||||||
|
- Only show this button to admin users
|
||||||
|
|
||||||
|
#### Scenario: Hide delete button for non-admin
|
||||||
|
- **WHEN** a non-admin user views a room detail page
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- NOT display permanent delete option
|
||||||
|
- Only show standard archive option (if owner)
|
||||||
|
|
||||||
|
#### Scenario: Confirm permanent deletion
|
||||||
|
- **WHEN** an admin clicks "Delete Room Permanently"
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Display a confirmation dialog with warning text
|
||||||
|
- Require typing room name to confirm (optional safety measure)
|
||||||
|
- Explain that deletion is irreversible
|
||||||
|
- Show what will be deleted (messages, files, reports)
|
||||||
|
|
||||||
|
#### Scenario: Execute permanent deletion
|
||||||
|
- **WHEN** an admin confirms permanent deletion
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Send DELETE request to `/api/rooms/{room_id}/permanent`
|
||||||
|
- Show loading state during deletion
|
||||||
|
- Navigate to room list on success
|
||||||
|
- Show success toast message
|
||||||
|
- Show error message on failure
|
||||||
|
|
||||||
|
#### Scenario: Handle active users in deleted room
|
||||||
|
- **WHEN** a room is permanently deleted while other users are viewing it
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Receive WebSocket disconnect event
|
||||||
|
- Display "Room has been deleted" message
|
||||||
|
- Navigate affected users to room list
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# Tasks: Add Admin Room Management
|
||||||
|
|
||||||
|
## Phase 1: Backend - Hide Archived Rooms
|
||||||
|
|
||||||
|
### 1.1 Modify room listing to exclude archived for non-admin
|
||||||
|
- [x] Update `room_service.list_user_rooms()` to filter out ARCHIVED status for non-admin
|
||||||
|
- [x] Ensure admin users can still see all statuses
|
||||||
|
- [x] Handle case where non-admin explicitly requests `status=archived` (return empty)
|
||||||
|
- [x] Write unit tests for filtered listing behavior
|
||||||
|
|
||||||
|
### 1.2 Update room count queries
|
||||||
|
- [x] Ensure total count excludes archived for non-admin
|
||||||
|
- [x] Verify pagination works correctly with filtered results
|
||||||
|
|
||||||
|
## Phase 2: Backend - Permanent Deletion
|
||||||
|
|
||||||
|
### 2.1 Fix room_files foreign key constraint
|
||||||
|
- [x] Add `ondelete="CASCADE"` to room_files.room_id foreign key
|
||||||
|
- [x] Create database migration or rebuild schema
|
||||||
|
|
||||||
|
### 2.2 Create permanent delete service method
|
||||||
|
- [x] Add `permanent_delete_room()` method to room_service
|
||||||
|
- [x] Implement cascading delete for all related tables
|
||||||
|
- [x] Add MinIO file cleanup logic
|
||||||
|
- [x] Handle WebSocket broadcast for room deletion event
|
||||||
|
|
||||||
|
### 2.3 Create permanent delete endpoint
|
||||||
|
- [x] Add `DELETE /api/rooms/{room_id}/permanent` endpoint
|
||||||
|
- [x] Implement admin-only authorization check
|
||||||
|
- [x] Return appropriate error responses (403 for non-admin, 404 for not found)
|
||||||
|
- [x] Write integration tests
|
||||||
|
|
||||||
|
## Phase 3: Frontend - Status Filter Changes
|
||||||
|
|
||||||
|
### 3.1 Add admin detection to frontend
|
||||||
|
- [x] Create utility to check if current user is admin
|
||||||
|
- [x] Store admin status in auth store or derive from username
|
||||||
|
|
||||||
|
### 3.2 Update room list status filter
|
||||||
|
- [x] Conditionally render filter options based on admin status
|
||||||
|
- [x] Remove "All Status" and "Archived" for non-admin users
|
||||||
|
- [x] Keep default filter as "Active"
|
||||||
|
- [x] Test filter behavior for both user types
|
||||||
|
|
||||||
|
## Phase 4: Frontend - Permanent Delete UI
|
||||||
|
|
||||||
|
### 4.1 Add delete button to room detail
|
||||||
|
- [x] Create "Delete Room Permanently" button (admin only)
|
||||||
|
- [x] Style with warning/danger color scheme
|
||||||
|
- [x] Position in room settings or header actions
|
||||||
|
|
||||||
|
### 4.2 Implement confirmation dialog
|
||||||
|
- [x] Create confirmation modal with warning text
|
||||||
|
- [x] List what will be deleted (members, messages, files, reports)
|
||||||
|
- [x] Add optional room name confirmation input
|
||||||
|
- [x] Implement cancel and confirm buttons
|
||||||
|
|
||||||
|
### 4.3 Handle deletion flow
|
||||||
|
- [x] Call DELETE `/api/rooms/{room_id}/permanent` on confirm
|
||||||
|
- [x] Show loading state during deletion
|
||||||
|
- [x] Navigate to room list on success
|
||||||
|
- [x] Display error toast on failure
|
||||||
|
|
||||||
|
### 4.4 Handle WebSocket room deletion event
|
||||||
|
- [x] Listen for room_deleted event in WebSocket handler
|
||||||
|
- [x] Display notification to affected users
|
||||||
|
- [x] Navigate users away from deleted room
|
||||||
|
|
||||||
|
## Phase 5: Testing & Validation
|
||||||
|
|
||||||
|
### 5.1 Backend tests
|
||||||
|
- [x] Test non-admin cannot see archived rooms
|
||||||
|
- [x] Test admin can see all rooms including archived
|
||||||
|
- [x] Test permanent delete endpoint authorization
|
||||||
|
- [x] Test cascading delete removes all related data
|
||||||
|
- [x] Test MinIO cleanup on permanent delete
|
||||||
|
|
||||||
|
### 5.2 Frontend tests
|
||||||
|
- [x] Test status filter options for admin vs non-admin
|
||||||
|
- [x] Test delete button visibility
|
||||||
|
- [x] Test confirmation dialog flow
|
||||||
|
- [x] Test WebSocket room deletion handling
|
||||||
|
|
||||||
|
## Validation Checklist
|
||||||
|
- [x] Run `openspec validate add-admin-room-management --strict`
|
||||||
|
- [x] All existing tests pass
|
||||||
|
- [x] New tests cover all scenarios
|
||||||
|
- [x] Manual testing of full admin flow
|
||||||
|
- [x] Manual testing of non-admin restrictions
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Proposal: Add Mobile Responsive Layout
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
Add device detection and responsive layout switching to optimize the user experience on mobile devices (<768px). The frontend currently only supports desktop and tablet layouts, leaving mobile users with horizontal scrolling issues and poor usability.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
- **Fixed sidebar widths** (w-72 for members, w-80 for files) cause horizontal overflow on mobile screens
|
||||||
|
- **Header controls** don't adapt to narrow screens, causing button wrapping issues
|
||||||
|
- **No mobile navigation pattern** - current horizontal nav doesn't collapse
|
||||||
|
- **Small touch targets** - buttons use px-3 py-1.5 which is difficult to tap on mobile
|
||||||
|
- Mobile devices are commonly used on the production floor for quick incident reporting
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### In Scope
|
||||||
|
1. **Device Detection Hook** (`useIsMobile`) - Detect mobile vs desktop using media queries
|
||||||
|
2. **Collapsible Sidebars** - Transform fixed sidebars into slide-in panels on mobile
|
||||||
|
3. **Mobile Header** - Simplified header with hamburger menu or action sheet
|
||||||
|
4. **Bottom Toolbar** - Move frequently used actions to bottom of screen for thumb-friendly access
|
||||||
|
5. **Touch-Friendly Sizing** - Increase button/input sizes on mobile
|
||||||
|
6. **Responsive Text** - Adjust font sizes for readability on small screens
|
||||||
|
|
||||||
|
### Out of Scope
|
||||||
|
- Native mobile app (PWA can be considered later)
|
||||||
|
- Offline functionality
|
||||||
|
- Push notifications
|
||||||
|
- Complex gestures (swipe to delete, etc.)
|
||||||
|
|
||||||
|
## Technical Approach
|
||||||
|
|
||||||
|
### Device Detection
|
||||||
|
```typescript
|
||||||
|
// hooks/useMediaQuery.ts
|
||||||
|
export function useIsMobile(): boolean {
|
||||||
|
return useMediaQuery('(max-width: 767px)')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useIsTablet(): boolean {
|
||||||
|
return useMediaQuery('(min-width: 768px) and (max-width: 1023px)')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Layout Strategy
|
||||||
|
- **Mobile (<768px)**: Single-column layout, slide-in sidebars, bottom action bar
|
||||||
|
- **Tablet (768-1023px)**: Current behavior with minor adjustments
|
||||||
|
- **Desktop (>1024px)**: Current behavior (unchanged)
|
||||||
|
|
||||||
|
### Key Component Changes
|
||||||
|
|
||||||
|
| Component | Mobile Behavior |
|
||||||
|
|-----------|----------------|
|
||||||
|
| RoomDetail Header | Collapse to hamburger menu, show essential actions only |
|
||||||
|
| Members Sidebar | Slide-in from right, full-screen overlay |
|
||||||
|
| Files Sidebar | Slide-in from right, full-screen overlay |
|
||||||
|
| Message Input | Sticky bottom with larger touch targets |
|
||||||
|
| Room Cards | Full-width single column |
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
- **frontend-core** spec: Add mobile-specific requirements to "Responsive Layout and Navigation"
|
||||||
|
- Estimated file changes: ~8 frontend files
|
||||||
|
- No backend changes required
|
||||||
|
|
||||||
|
## Related Changes
|
||||||
|
- None (standalone improvement)
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# mobile-layout Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Provide responsive layout capabilities that detect user devices and adapt the UI for optimal mobile experience. This extends the existing "Responsive Layout and Navigation" requirement in frontend-core to include mobile devices (<768px).
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Responsive Layout and Navigation
|
||||||
|
The frontend SHALL provide a responsive layout that works on desktop, tablet, and **mobile** devices with intuitive navigation. The system SHALL detect the device type and switch layouts accordingly.
|
||||||
|
|
||||||
|
#### Scenario: Mobile layout (<768px)
|
||||||
|
- **WHEN** viewed on mobile devices (<768px width)
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Display a simplified header with hamburger menu
|
||||||
|
- Show sidebars as full-screen slide-in panels (not inline)
|
||||||
|
- Display a bottom toolbar with frequently used actions
|
||||||
|
- Use single-column layout for content
|
||||||
|
- Ensure all touch targets are at least 44x44 pixels
|
||||||
|
|
||||||
|
#### Scenario: Tablet layout (768px-1024px)
|
||||||
|
- **WHEN** viewed on tablet (768px-1024px)
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Collapse sidebars to icons or overlay panels
|
||||||
|
- Use full width for content areas
|
||||||
|
- Stack panels vertically when needed
|
||||||
|
|
||||||
|
#### Scenario: Desktop layout (>1024px)
|
||||||
|
- **WHEN** viewed on desktop (>1024px)
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Display full navigation and sidebars inline
|
||||||
|
- Show room list and detail side-by-side when applicable
|
||||||
|
- Display member/file panels as sidebars
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Device Detection
|
||||||
|
The frontend SHALL provide hooks for detecting device type based on viewport width.
|
||||||
|
|
||||||
|
#### Scenario: Detect mobile device
|
||||||
|
- **WHEN** the viewport width is less than 768px
|
||||||
|
- **THEN** `useIsMobile()` hook SHALL return `true`
|
||||||
|
- **AND** the UI SHALL render mobile-optimized components
|
||||||
|
|
||||||
|
#### Scenario: Detect tablet device
|
||||||
|
- **WHEN** the viewport width is between 768px and 1023px
|
||||||
|
- **THEN** `useIsTablet()` hook SHALL return `true`
|
||||||
|
|
||||||
|
#### Scenario: Detect desktop device
|
||||||
|
- **WHEN** the viewport width is 1024px or greater
|
||||||
|
- **THEN** `useIsDesktop()` hook SHALL return `true`
|
||||||
|
|
||||||
|
#### Scenario: Handle viewport resize
|
||||||
|
- **WHEN** the user resizes the browser window across breakpoints
|
||||||
|
- **THEN** the hooks SHALL update their return values
|
||||||
|
- **AND** the UI SHALL transition smoothly to the appropriate layout
|
||||||
|
|
||||||
|
### Requirement: Mobile Navigation
|
||||||
|
The frontend SHALL provide mobile-optimized navigation patterns for small screens.
|
||||||
|
|
||||||
|
#### Scenario: Display mobile header
|
||||||
|
- **WHEN** on mobile layout in room detail view
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Show room title (truncated if necessary)
|
||||||
|
- Show connection status indicator
|
||||||
|
- Show hamburger menu button
|
||||||
|
- Hide secondary action buttons (move to menu)
|
||||||
|
|
||||||
|
#### Scenario: Open mobile action menu
|
||||||
|
- **WHEN** user taps the hamburger menu on mobile
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Display an action drawer/sheet from bottom or side
|
||||||
|
- Show all room actions: Generate Report, Change Status, etc.
|
||||||
|
- Allow closing by tapping outside or swipe gesture
|
||||||
|
|
||||||
|
#### Scenario: Display bottom toolbar
|
||||||
|
- **WHEN** on mobile layout in room detail view
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Show a fixed bottom toolbar above the message input
|
||||||
|
- Include buttons for: Files, Members
|
||||||
|
- Use icons with labels for clarity
|
||||||
|
- Ensure toolbar doesn't overlap with device safe areas
|
||||||
|
|
||||||
|
### Requirement: Mobile Sidebars
|
||||||
|
The frontend SHALL display sidebars as slide-in panels on mobile devices.
|
||||||
|
|
||||||
|
#### Scenario: Open members panel on mobile
|
||||||
|
- **WHEN** user taps the Members button on mobile
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Slide in a full-screen panel from the right
|
||||||
|
- Display dark overlay behind the panel
|
||||||
|
- Show member list with larger touch targets
|
||||||
|
- Include a close button at the top of the panel
|
||||||
|
|
||||||
|
#### Scenario: Close sidebar panel
|
||||||
|
- **WHEN** user taps the close button or backdrop overlay
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Slide the panel out to the right
|
||||||
|
- Remove the dark overlay
|
||||||
|
- Return focus to the main content
|
||||||
|
|
||||||
|
#### Scenario: Open files panel on mobile
|
||||||
|
- **WHEN** user taps the Files button on mobile
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Slide in a full-screen panel from the right
|
||||||
|
- Display file list with larger thumbnails
|
||||||
|
- Show upload area with larger drop zone
|
||||||
|
- Include a close button at the top
|
||||||
|
|
||||||
|
### Requirement: Touch-Friendly Interactions
|
||||||
|
The frontend SHALL ensure all interactive elements are usable on touch devices.
|
||||||
|
|
||||||
|
#### Scenario: Minimum touch target size
|
||||||
|
- **WHEN** displaying buttons, links, or interactive elements on mobile
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Ensure minimum touch target of 44x44 pixels
|
||||||
|
- Provide adequate spacing between touch targets
|
||||||
|
- Use padding to expand touch areas without changing visual size
|
||||||
|
|
||||||
|
#### Scenario: Mobile message input
|
||||||
|
- **WHEN** user focuses the message input on mobile
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Expand input area for easier typing
|
||||||
|
- Keep send button easily accessible
|
||||||
|
- Handle soft keyboard appearance gracefully
|
||||||
|
- Not obscure the input behind the keyboard
|
||||||
|
|
||||||
|
#### Scenario: Mobile form inputs
|
||||||
|
- **WHEN** displaying form inputs on mobile (login, add member, etc.)
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Use larger input fields (minimum height 44px)
|
||||||
|
- Show appropriate mobile keyboard type (email, text, etc.)
|
||||||
|
- Support autocomplete where appropriate
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# Tasks: Add Mobile Responsive Layout
|
||||||
|
|
||||||
|
## Phase 1: Foundation - Device Detection
|
||||||
|
|
||||||
|
- [x] **T-1.1**: Create `useMediaQuery` hook for responsive breakpoint detection
|
||||||
|
- Create `/frontend/src/hooks/useMediaQuery.ts`
|
||||||
|
- Export `useMediaQuery`, `useIsMobile`, `useIsTablet`, `useIsDesktop`
|
||||||
|
- Add unit tests for hook behavior
|
||||||
|
|
||||||
|
- [x] **T-1.2**: Add mobile viewport meta and CSS custom properties
|
||||||
|
- Ensure viewport meta tag is properly configured
|
||||||
|
- Add CSS custom properties for safe area insets (notch handling)
|
||||||
|
|
||||||
|
## Phase 2: Mobile Navigation & Header
|
||||||
|
|
||||||
|
- [x] **T-2.1**: Create `MobileHeader` component with hamburger menu
|
||||||
|
- Show essential info: room title, status, connection indicator
|
||||||
|
- Hamburger button triggers action drawer
|
||||||
|
- Implement slide-in action drawer for secondary actions
|
||||||
|
|
||||||
|
- [x] **T-2.2**: Create `BottomToolbar` component for mobile
|
||||||
|
- Show: Files toggle, Members toggle, Message input trigger
|
||||||
|
- Fixed position at bottom, above keyboard when active
|
||||||
|
- Touch-friendly button sizes (min 44x44px)
|
||||||
|
|
||||||
|
- [x] **T-2.3**: Update `RoomDetail` header to switch between desktop and mobile layouts
|
||||||
|
- Use `useIsMobile()` to conditionally render
|
||||||
|
- Desktop: Current header layout
|
||||||
|
- Mobile: `MobileHeader` + `BottomToolbar`
|
||||||
|
|
||||||
|
## Phase 3: Collapsible Sidebars
|
||||||
|
|
||||||
|
- [x] **T-3.1**: Create `SlidePanel` component for mobile sidebars
|
||||||
|
- Full-height slide-in from right
|
||||||
|
- Dark overlay backdrop
|
||||||
|
- Close on backdrop click or swipe
|
||||||
|
- Smooth CSS transitions
|
||||||
|
|
||||||
|
- [x] **T-3.2**: Update Members sidebar to use `SlidePanel` on mobile
|
||||||
|
- Wrap existing member list content
|
||||||
|
- Add close button at top
|
||||||
|
- Full-screen width on mobile
|
||||||
|
|
||||||
|
- [x] **T-3.3**: Update Files sidebar to use `SlidePanel` on mobile
|
||||||
|
- Wrap existing file list and upload area
|
||||||
|
- Larger upload drop zone on mobile
|
||||||
|
- Full-screen width on mobile
|
||||||
|
|
||||||
|
## Phase 4: Room List Mobile Optimization
|
||||||
|
|
||||||
|
- [x] **T-4.1**: Update `RoomList` layout for mobile
|
||||||
|
- Full-width room cards (remove grid on mobile)
|
||||||
|
- Stack filter controls vertically
|
||||||
|
- Increase touch targets on filter buttons
|
||||||
|
|
||||||
|
- [x] **T-4.2**: Add mobile-optimized room card design
|
||||||
|
- Larger status/severity badges
|
||||||
|
- More padding for touch targets
|
||||||
|
- Truncate long descriptions
|
||||||
|
|
||||||
|
## Phase 5: Touch-Friendly Inputs
|
||||||
|
|
||||||
|
- [x] **T-5.1**: Update message input for mobile
|
||||||
|
- Larger input field (min height 44px)
|
||||||
|
- Larger send button
|
||||||
|
- Handle keyboard appearance gracefully
|
||||||
|
|
||||||
|
- [x] **T-5.2**: Update button and input sizes globally on mobile
|
||||||
|
- Minimum touch target 44x44px
|
||||||
|
- Larger form inputs
|
||||||
|
- Use Tailwind responsive variants (sm: prefixes)
|
||||||
|
|
||||||
|
## Phase 6: Testing & Validation
|
||||||
|
|
||||||
|
- [x] **T-6.1**: Add responsive tests for key components
|
||||||
|
- Test `useMediaQuery` hook with different window sizes
|
||||||
|
- Test component rendering at mobile breakpoint
|
||||||
|
- Test sidebar open/close behavior
|
||||||
|
|
||||||
|
- [x] **T-6.2**: Manual testing on actual devices
|
||||||
|
- Test on iPhone Safari
|
||||||
|
- Test on Android Chrome
|
||||||
|
- Test keyboard interactions
|
||||||
|
- Test safe area handling (notch)
|
||||||
|
|
||||||
|
- [x] **T-6.3**: Run full test suite and fix any regressions
|
||||||
|
- `npm run test:run`
|
||||||
|
- `npm run build`
|
||||||
|
- Fix any TypeScript or lint errors
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- T-1.1 must complete before T-2.x and T-3.x
|
||||||
|
- T-3.1 must complete before T-3.2 and T-3.3
|
||||||
|
- T-6.x depends on all other tasks
|
||||||
|
|
||||||
|
## Parallelizable Work
|
||||||
|
- T-2.x (Navigation) and T-3.x (Sidebars) can run in parallel after T-1.1
|
||||||
|
- T-4.x (Room List) can run in parallel with T-2.x and T-3.x
|
||||||
@@ -52,23 +52,18 @@ The system SHALL allow authenticated users to create a new incident room with me
|
|||||||
- **THEN** the system SHALL return status 401 with "Authentication required"
|
- **THEN** the system SHALL return status 401 with "Authentication required"
|
||||||
|
|
||||||
### Requirement: List and Filter Incident Rooms
|
### Requirement: List and Filter Incident Rooms
|
||||||
The system SHALL provide endpoints to list incident rooms with filtering capabilities by status, incident type, severity, date range, and user membership.
|
The system SHALL provide endpoints to list incident rooms with filtering capabilities by status, incident type, severity, date range, and user membership. The system SHALL automatically exclude rooms with ARCHIVED status from listing results for non-admin users, ensuring archived rooms are only visible to system administrators.
|
||||||
|
|
||||||
#### Scenario: List all active rooms for current user
|
#### Scenario: List all active rooms for current user
|
||||||
- **WHEN** an authenticated user sends `GET /api/rooms?status=active`
|
- **WHEN** an authenticated user sends `GET /api/rooms?status=active`
|
||||||
- **THEN** the system SHALL return all active rooms where the user is a member
|
- **THEN** the system SHALL return all active rooms
|
||||||
- **AND** include room metadata (title, type, severity, member count, last activity)
|
- **AND** include room metadata (title, type, severity, member count, last activity)
|
||||||
- **AND** sort by last_activity_at descending (most recent first)
|
- **AND** sort by last_activity_at descending (most recent first)
|
||||||
|
|
||||||
#### Scenario: Filter rooms by incident type and date range
|
#### Scenario: Non-admin user lists rooms without status filter
|
||||||
- **WHEN** a user sends `GET /api/rooms?incident_type=quality_issue&created_after=2025-01-01&created_before=2025-01-31`
|
- **WHEN** a non-admin user sends `GET /api/rooms` without status parameter
|
||||||
- **THEN** the system SHALL return rooms matching ALL filter criteria
|
- **THEN** the system SHALL return rooms with status "active" or "resolved" only
|
||||||
- **AND** only include rooms where the user is a member
|
- **AND** automatically exclude archived rooms from results
|
||||||
|
|
||||||
#### Scenario: Search rooms by title or description
|
|
||||||
- **WHEN** a user sends `GET /api/rooms?search=conveyor`
|
|
||||||
- **THEN** the system SHALL return rooms where title OR description contains "conveyor" (case-insensitive)
|
|
||||||
- **AND** highlight matching terms in the response
|
|
||||||
|
|
||||||
### Requirement: Manage Room Membership
|
### Requirement: Manage Room Membership
|
||||||
The system SHALL allow room owners and members with appropriate permissions to add or remove members and assign roles (owner, editor, viewer). Room owners SHALL be able to transfer ownership to another member. System administrators SHALL have override capabilities for all membership operations.
|
The system SHALL allow room owners and members with appropriate permissions to add or remove members and assign roles (owner, editor, viewer). Room owners SHALL be able to transfer ownership to another member. System administrators SHALL have override capabilities for all membership operations.
|
||||||
@@ -218,3 +213,50 @@ The system SHALL support predefined room templates for common incident types to
|
|||||||
- Default values for each template
|
- Default values for each template
|
||||||
- Required additional fields
|
- Required additional fields
|
||||||
|
|
||||||
|
### Requirement: Admin Permanent Room Deletion
|
||||||
|
The system SHALL provide system administrators with the ability to permanently delete rooms, including all associated data (members, messages, files, reports). This operation is irreversible and restricted to system administrators only.
|
||||||
|
|
||||||
|
#### Scenario: Admin permanently deletes a room
|
||||||
|
- **WHEN** a system administrator sends `DELETE /api/rooms/{room_id}/permanent`
|
||||||
|
- **THEN** the system SHALL verify the user is ymirliu@panjit.com.tw
|
||||||
|
- **AND** hard delete the room record from incident_rooms table
|
||||||
|
- **AND** cascade delete all room_members records
|
||||||
|
- **AND** cascade delete all messages and related reactions/edit_history
|
||||||
|
- **AND** cascade delete all room_files records
|
||||||
|
- **AND** delete associated files from MinIO storage
|
||||||
|
- **AND** cascade delete all generated_reports records
|
||||||
|
- **AND** delete associated report files from MinIO storage
|
||||||
|
- **AND** broadcast disconnect event to any active WebSocket connections in the room
|
||||||
|
- **AND** return status 200 with `{"message": "Room permanently deleted"}`
|
||||||
|
|
||||||
|
#### Scenario: Non-admin attempts permanent deletion
|
||||||
|
- **WHEN** a non-admin user sends `DELETE /api/rooms/{room_id}/permanent`
|
||||||
|
- **THEN** the system SHALL return status 403 with "Only system administrators can permanently delete rooms"
|
||||||
|
|
||||||
|
#### Scenario: Permanent delete non-existent room
|
||||||
|
- **WHEN** a system administrator sends `DELETE /api/rooms/{room_id}/permanent` for a non-existent room
|
||||||
|
- **THEN** the system SHALL return status 404 with "Room not found"
|
||||||
|
|
||||||
|
### Requirement: Hide Archived Rooms from Non-Admin Users
|
||||||
|
The system SHALL hide rooms with ARCHIVED status from non-admin users in all listing operations, ensuring historical/archived data is only visible to system administrators.
|
||||||
|
|
||||||
|
#### Scenario: Non-admin lists rooms with any filter
|
||||||
|
- **WHEN** a non-admin user sends `GET /api/rooms` with any status filter (including no filter)
|
||||||
|
- **THEN** the system SHALL exclude all rooms with status "archived" from the response
|
||||||
|
- **AND** only return rooms with status "active" or "resolved"
|
||||||
|
|
||||||
|
#### Scenario: Non-admin explicitly requests archived rooms
|
||||||
|
- **WHEN** a non-admin user sends `GET /api/rooms?status=archived`
|
||||||
|
- **THEN** the system SHALL return an empty list
|
||||||
|
- **AND** return total count of 0
|
||||||
|
|
||||||
|
#### Scenario: Admin can view archived rooms
|
||||||
|
- **WHEN** a system administrator sends `GET /api/rooms?status=archived`
|
||||||
|
- **THEN** the system SHALL return all archived rooms
|
||||||
|
- **AND** include full room details
|
||||||
|
|
||||||
|
#### Scenario: Admin views all rooms including archived
|
||||||
|
- **WHEN** a system administrator sends `GET /api/rooms` without status filter
|
||||||
|
- **THEN** the system SHALL return all rooms regardless of status
|
||||||
|
- **AND** include archived rooms in the response
|
||||||
|
|
||||||
|
|||||||
@@ -36,35 +36,26 @@ The frontend SHALL provide a login interface that authenticates users against th
|
|||||||
- Redirect to login page
|
- Redirect to login page
|
||||||
|
|
||||||
### Requirement: Incident Room List
|
### Requirement: Incident Room List
|
||||||
The frontend SHALL display a filterable, searchable list of incident rooms accessible to the current user.
|
The frontend SHALL display a filterable, searchable list of incident rooms accessible to the current user. The frontend SHALL restrict the status filter options to show only "Active" and "Resolved" for non-admin users, and SHALL display all status options including "Archived" only for system administrators.
|
||||||
|
|
||||||
#### Scenario: Display room list
|
#### Scenario: Filter rooms by status (Non-admin)
|
||||||
- **WHEN** a logged-in user navigates to the room list page
|
- **WHEN** a non-admin user views the status filter dropdown
|
||||||
- **THEN** the system SHALL:
|
- **THEN** the system SHALL:
|
||||||
- Fetch rooms from `GET /api/rooms`
|
- Display only "Active" and "Resolved" options
|
||||||
- Display rooms as cards with title, status, severity, and timestamp
|
- NOT display "Archived" option
|
||||||
- Show the user's role in each room
|
- NOT display "All Status" option that would include archived rooms
|
||||||
- Order by last activity (most recent first)
|
|
||||||
|
|
||||||
#### Scenario: Filter rooms by status
|
#### Scenario: Filter rooms by status (Admin)
|
||||||
- **WHEN** a user selects a status filter (Active, Resolved, Archived)
|
- **WHEN** a system administrator views the status filter dropdown
|
||||||
- **THEN** the system SHALL:
|
- **THEN** the system SHALL:
|
||||||
- Update the room list to show only rooms matching the filter
|
- Display all status options: "All Status", "Active", "Resolved", "Archived"
|
||||||
- Preserve other active filters
|
- Allow viewing archived rooms
|
||||||
|
|
||||||
#### Scenario: Search rooms
|
#### Scenario: Default status filter
|
||||||
- **WHEN** a user enters text in the search box
|
- **WHEN** a user navigates to the room list page
|
||||||
- **THEN** the system SHALL:
|
- **THEN** the system SHALL:
|
||||||
- Filter rooms by title and description containing the search text
|
- Default to "Active" status filter for all users
|
||||||
- Update results in real-time (debounced)
|
- Fetch only active rooms initially
|
||||||
|
|
||||||
#### Scenario: Create new room
|
|
||||||
- **WHEN** a user clicks "New Room" and fills the creation form
|
|
||||||
- **THEN** the system SHALL:
|
|
||||||
- Display template selection if templates exist
|
|
||||||
- Submit room creation to `POST /api/rooms`
|
|
||||||
- Navigate to the new room on success
|
|
||||||
- Show error message on failure
|
|
||||||
|
|
||||||
### Requirement: Incident Room Detail View
|
### Requirement: Incident Room Detail View
|
||||||
The frontend SHALL display complete incident room information including metadata, members, and provide management controls for authorized users.
|
The frontend SHALL display complete incident room information including metadata, members, and provide management controls for authorized users.
|
||||||
@@ -270,3 +261,43 @@ The frontend SHALL provide a responsive layout that works on desktop and tablet
|
|||||||
- Log error details for debugging
|
- Log error details for debugging
|
||||||
- Not crash or show blank screen
|
- Not crash or show blank screen
|
||||||
|
|
||||||
|
### Requirement: Admin Room Deletion Interface
|
||||||
|
The frontend SHALL provide system administrators with the ability to permanently delete rooms through a dedicated UI control.
|
||||||
|
|
||||||
|
#### Scenario: Display delete button for admin
|
||||||
|
- **WHEN** a system administrator views a room detail page
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Display a "Delete Room Permanently" button in room settings/actions
|
||||||
|
- Style the button with warning color (red)
|
||||||
|
- Only show this button to admin users
|
||||||
|
|
||||||
|
#### Scenario: Hide delete button for non-admin
|
||||||
|
- **WHEN** a non-admin user views a room detail page
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- NOT display permanent delete option
|
||||||
|
- Only show standard archive option (if owner)
|
||||||
|
|
||||||
|
#### Scenario: Confirm permanent deletion
|
||||||
|
- **WHEN** an admin clicks "Delete Room Permanently"
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Display a confirmation dialog with warning text
|
||||||
|
- Require typing room name to confirm (optional safety measure)
|
||||||
|
- Explain that deletion is irreversible
|
||||||
|
- Show what will be deleted (messages, files, reports)
|
||||||
|
|
||||||
|
#### Scenario: Execute permanent deletion
|
||||||
|
- **WHEN** an admin confirms permanent deletion
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Send DELETE request to `/api/rooms/{room_id}/permanent`
|
||||||
|
- Show loading state during deletion
|
||||||
|
- Navigate to room list on success
|
||||||
|
- Show success toast message
|
||||||
|
- Show error message on failure
|
||||||
|
|
||||||
|
#### Scenario: Handle active users in deleted room
|
||||||
|
- **WHEN** a room is permanently deleted while other users are viewing it
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Receive WebSocket disconnect event
|
||||||
|
- Display "Room has been deleted" message
|
||||||
|
- Navigate affected users to room list
|
||||||
|
|
||||||
|
|||||||
132
openspec/specs/mobile-layout/spec.md
Normal file
132
openspec/specs/mobile-layout/spec.md
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
# mobile-layout Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Provide responsive layout capabilities that detect user devices and adapt the UI for optimal mobile experience. This extends the existing "Responsive Layout and Navigation" requirement in frontend-core to include mobile devices (<768px).
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Responsive Layout and Navigation
|
||||||
|
The frontend SHALL provide a responsive layout that works on desktop, tablet, and **mobile** devices with intuitive navigation. The system SHALL detect the device type and switch layouts accordingly.
|
||||||
|
|
||||||
|
#### Scenario: Mobile layout (<768px)
|
||||||
|
- **WHEN** viewed on mobile devices (<768px width)
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Display a simplified header with hamburger menu
|
||||||
|
- Show sidebars as full-screen slide-in panels (not inline)
|
||||||
|
- Display a bottom toolbar with frequently used actions
|
||||||
|
- Use single-column layout for content
|
||||||
|
- Ensure all touch targets are at least 44x44 pixels
|
||||||
|
|
||||||
|
#### Scenario: Tablet layout (768px-1024px)
|
||||||
|
- **WHEN** viewed on tablet (768px-1024px)
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Collapse sidebars to icons or overlay panels
|
||||||
|
- Use full width for content areas
|
||||||
|
- Stack panels vertically when needed
|
||||||
|
|
||||||
|
#### Scenario: Desktop layout (>1024px)
|
||||||
|
- **WHEN** viewed on desktop (>1024px)
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Display full navigation and sidebars inline
|
||||||
|
- Show room list and detail side-by-side when applicable
|
||||||
|
- Display member/file panels as sidebars
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Device Detection
|
||||||
|
The frontend SHALL provide hooks for detecting device type based on viewport width.
|
||||||
|
|
||||||
|
#### Scenario: Detect mobile device
|
||||||
|
- **WHEN** the viewport width is less than 768px
|
||||||
|
- **THEN** `useIsMobile()` hook SHALL return `true`
|
||||||
|
- **AND** the UI SHALL render mobile-optimized components
|
||||||
|
|
||||||
|
#### Scenario: Detect tablet device
|
||||||
|
- **WHEN** the viewport width is between 768px and 1023px
|
||||||
|
- **THEN** `useIsTablet()` hook SHALL return `true`
|
||||||
|
|
||||||
|
#### Scenario: Detect desktop device
|
||||||
|
- **WHEN** the viewport width is 1024px or greater
|
||||||
|
- **THEN** `useIsDesktop()` hook SHALL return `true`
|
||||||
|
|
||||||
|
#### Scenario: Handle viewport resize
|
||||||
|
- **WHEN** the user resizes the browser window across breakpoints
|
||||||
|
- **THEN** the hooks SHALL update their return values
|
||||||
|
- **AND** the UI SHALL transition smoothly to the appropriate layout
|
||||||
|
|
||||||
|
### Requirement: Mobile Navigation
|
||||||
|
The frontend SHALL provide mobile-optimized navigation patterns for small screens.
|
||||||
|
|
||||||
|
#### Scenario: Display mobile header
|
||||||
|
- **WHEN** on mobile layout in room detail view
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Show room title (truncated if necessary)
|
||||||
|
- Show connection status indicator
|
||||||
|
- Show hamburger menu button
|
||||||
|
- Hide secondary action buttons (move to menu)
|
||||||
|
|
||||||
|
#### Scenario: Open mobile action menu
|
||||||
|
- **WHEN** user taps the hamburger menu on mobile
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Display an action drawer/sheet from bottom or side
|
||||||
|
- Show all room actions: Generate Report, Change Status, etc.
|
||||||
|
- Allow closing by tapping outside or swipe gesture
|
||||||
|
|
||||||
|
#### Scenario: Display bottom toolbar
|
||||||
|
- **WHEN** on mobile layout in room detail view
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Show a fixed bottom toolbar above the message input
|
||||||
|
- Include buttons for: Files, Members
|
||||||
|
- Use icons with labels for clarity
|
||||||
|
- Ensure toolbar doesn't overlap with device safe areas
|
||||||
|
|
||||||
|
### Requirement: Mobile Sidebars
|
||||||
|
The frontend SHALL display sidebars as slide-in panels on mobile devices.
|
||||||
|
|
||||||
|
#### Scenario: Open members panel on mobile
|
||||||
|
- **WHEN** user taps the Members button on mobile
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Slide in a full-screen panel from the right
|
||||||
|
- Display dark overlay behind the panel
|
||||||
|
- Show member list with larger touch targets
|
||||||
|
- Include a close button at the top of the panel
|
||||||
|
|
||||||
|
#### Scenario: Close sidebar panel
|
||||||
|
- **WHEN** user taps the close button or backdrop overlay
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Slide the panel out to the right
|
||||||
|
- Remove the dark overlay
|
||||||
|
- Return focus to the main content
|
||||||
|
|
||||||
|
#### Scenario: Open files panel on mobile
|
||||||
|
- **WHEN** user taps the Files button on mobile
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Slide in a full-screen panel from the right
|
||||||
|
- Display file list with larger thumbnails
|
||||||
|
- Show upload area with larger drop zone
|
||||||
|
- Include a close button at the top
|
||||||
|
|
||||||
|
### Requirement: Touch-Friendly Interactions
|
||||||
|
The frontend SHALL ensure all interactive elements are usable on touch devices.
|
||||||
|
|
||||||
|
#### Scenario: Minimum touch target size
|
||||||
|
- **WHEN** displaying buttons, links, or interactive elements on mobile
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Ensure minimum touch target of 44x44 pixels
|
||||||
|
- Provide adequate spacing between touch targets
|
||||||
|
- Use padding to expand touch areas without changing visual size
|
||||||
|
|
||||||
|
#### Scenario: Mobile message input
|
||||||
|
- **WHEN** user focuses the message input on mobile
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Expand input area for easier typing
|
||||||
|
- Keep send button easily accessible
|
||||||
|
- Handle soft keyboard appearance gracefully
|
||||||
|
- Not obscure the input behind the keyboard
|
||||||
|
|
||||||
|
#### Scenario: Mobile form inputs
|
||||||
|
- **WHEN** displaying form inputs on mobile (login, add member, etc.)
|
||||||
|
- **THEN** the system SHALL:
|
||||||
|
- Use larger input fields (minimum height 44px)
|
||||||
|
- Show appropriate mobile keyboard type (email, text, etc.)
|
||||||
|
- Support autocomplete where appropriate
|
||||||
61
stop-dev.sh
61
stop-dev.sh
@@ -168,6 +168,67 @@ else
|
|||||||
print_info "Keeping MinIO running (--keep-minio)"
|
print_info "Keeping MinIO running (--keep-minio)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Port Release
|
||||||
|
# ============================================================================
|
||||||
|
print_header "Port Release"
|
||||||
|
|
||||||
|
# Function to release port
|
||||||
|
release_port() {
|
||||||
|
local port=$1
|
||||||
|
local service_name=$2
|
||||||
|
|
||||||
|
# Check if port is in use
|
||||||
|
if command -v fuser &> /dev/null; then
|
||||||
|
# Use fuser if available
|
||||||
|
if fuser "$port/tcp" > /dev/null 2>&1; then
|
||||||
|
print_info "Releasing port $port ($service_name)..."
|
||||||
|
fuser -k "$port/tcp" > /dev/null 2>&1 || true
|
||||||
|
sleep 1
|
||||||
|
if ! fuser "$port/tcp" > /dev/null 2>&1; then
|
||||||
|
print_ok "Port $port released"
|
||||||
|
else
|
||||||
|
print_warn "Port $port may still be in use (TIME_WAIT state)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
print_ok "Port $port is free"
|
||||||
|
fi
|
||||||
|
elif command -v lsof &> /dev/null; then
|
||||||
|
# Fallback to lsof
|
||||||
|
local pid=$(lsof -ti ":$port" 2>/dev/null | head -1)
|
||||||
|
if [[ -n "$pid" ]]; then
|
||||||
|
print_info "Releasing port $port ($service_name, PID: $pid)..."
|
||||||
|
kill "$pid" 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
if ! lsof -ti ":$port" > /dev/null 2>&1; then
|
||||||
|
print_ok "Port $port released"
|
||||||
|
else
|
||||||
|
# Force kill if still in use
|
||||||
|
kill -9 "$pid" 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
if ! lsof -ti ":$port" > /dev/null 2>&1; then
|
||||||
|
print_ok "Port $port force released"
|
||||||
|
else
|
||||||
|
print_warn "Port $port may still be in use (TIME_WAIT state)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
print_ok "Port $port is free"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Check with netstat/ss as last resort
|
||||||
|
if ss -tuln 2>/dev/null | grep -q ":$port "; then
|
||||||
|
print_warn "Port $port appears in use but no tool to release it"
|
||||||
|
else
|
||||||
|
print_ok "Port $port is free"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Release application ports (not MinIO ports - those are managed by Docker)
|
||||||
|
release_port 8000 "Backend API"
|
||||||
|
release_port 3000 "Frontend"
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Cleanup
|
# Cleanup
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user