diff --git a/api/src/rehearsalhub/main.py b/api/src/rehearsalhub/main.py index 3ac4f34..1f0cacf 100644 --- a/api/src/rehearsalhub/main.py +++ b/api/src/rehearsalhub/main.py @@ -1,9 +1,11 @@ """RehearsalHub FastAPI application entry point.""" from contextlib import asynccontextmanager +import os from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles from rehearsalhub.config import get_settings from rehearsalhub.routers import ( @@ -64,6 +66,11 @@ def create_app() -> FastAPI: async def health(): return {"status": "ok"} + # Mount static files for avatar uploads + upload_dir = "uploads/avatars" + os.makedirs(upload_dir, exist_ok=True) + app.mount("/api/static/avatars", StaticFiles(directory=upload_dir), name="avatars") + return app diff --git a/api/src/rehearsalhub/routers/auth.py b/api/src/rehearsalhub/routers/auth.py index f30c05a..2595992 100644 --- a/api/src/rehearsalhub/routers/auth.py +++ b/api/src/rehearsalhub/routers/auth.py @@ -1,5 +1,7 @@ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File from sqlalchemy.ext.asyncio import AsyncSession +import os +import uuid from rehearsalhub.db.engine import get_session from rehearsalhub.db.models import Member @@ -62,3 +64,44 @@ async def update_settings( else: member = current_member return MemberRead.from_model(member) + + +@router.post("/me/avatar", response_model=MemberRead) +async def upload_avatar( + file: UploadFile = File(...), + session: AsyncSession = Depends(get_session), + current_member: Member = Depends(get_current_member), +): + """Upload and set user avatar image.""" + # Validate file type and size + if not file.content_type.startswith("image/"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Only image files are allowed" + ) + + # Create uploads directory if it doesn't exist + upload_dir = "uploads/avatars" + os.makedirs(upload_dir, exist_ok=True) + + # Generate unique filename + file_ext = file.filename.split(".")[-1] if "." in file.filename else "jpg" + filename = f"{uuid.uuid4()}.{file_ext}" + file_path = f"{upload_dir}/{filename}" + + # Save file + try: + with open(file_path, "wb") as buffer: + buffer.write(await file.read()) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to save avatar: {str(e)}" + ) + + # Update member's avatar URL + repo = MemberRepository(session) + avatar_url = f"/api/static/avatars/{filename}" + member = await repo.update(current_member, avatar_url=avatar_url) + + return MemberRead.from_model(member) diff --git a/test_websocket.html b/test_websocket.html deleted file mode 100644 index c3d823f..0000000 --- a/test_websocket.html +++ /dev/null @@ -1,40 +0,0 @@ - - - - WebSocket Test - - -

WebSocket Test

-

Connecting...

- - - \ No newline at end of file diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index 5365d6c..64e5bc8 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -40,6 +40,7 @@ function SettingsForm({ me, onBack }: { me: MemberRead; onBack: () => void }) { const [ncUsername, setNcUsername] = useState(me.nc_username ?? ""); const [ncPassword, setNcPassword] = useState(""); const [avatarUrl, setAvatarUrl] = useState(me.avatar_url ?? ""); + const [uploading, setUploading] = useState(false); const [saved, setSaved] = useState(false); const [error, setError] = useState(null); @@ -120,17 +121,27 @@ function SettingsForm({ me, onBack }: { me: MemberRead; onBack: () => void }) { { + onChange={async (e) => { const file = e.target.files?.[0]; if (file) { - // In a real app, you would upload the file to a server - // and get a URL back. For now, we'll use a placeholder. - const reader = new FileReader(); - reader.onload = (event) => { - // This is a simplified approach - in production you'd upload to server - setAvatarUrl(event.target?.result as string); - }; - reader.readAsDataURL(file); + setUploading(true); + try { + const formData = new FormData(); + formData.append('file', file); + + const response = await api.post('/auth/me/avatar', formData, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }); + + setAvatarUrl(response.avatar_url || ''); + qc.invalidateQueries({ queryKey: ['me'] }); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to upload avatar'); + } finally { + setUploading(false); + } } }} style={{ display: "none" }} @@ -146,13 +157,21 @@ function SettingsForm({ me, onBack }: { me: MemberRead; onBack: () => void }) { fontWeight: 600, fontSize: 14 }}> - Upload Avatar + {uploading ? "Uploading..." : "Upload Avatar"}