diff --git a/backend/app/chat_history.py b/backend/app/chat_history.py index b73e787..6c396ab 100644 --- a/backend/app/chat_history.py +++ b/backend/app/chat_history.py @@ -77,3 +77,13 @@ async def update_title(chat_id: str, title: str): {"_id": ObjectId(chat_id)}, {"$set": {"title": title}} ) + + @staticmethod + async def delete_chat(chat_id: str, user_id: str): + if db.db is None: return None + result = await db.db.chats.delete_one({ + "_id": ObjectId(chat_id), + "user_id": ObjectId(user_id) + }) + return result.deleted_count > 0 + diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index 67e1b04..2c0961a 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -5,9 +5,7 @@ from ..users import User from pydantic import BaseModel, EmailStr from typing import Dict, Any, Optional -import os -import shutil -import uuid +import base64 router = APIRouter() @@ -54,6 +52,14 @@ async def login(form_data: OAuth2PasswordRequestForm = Depends()): user_data = {k: v for k, v in user.items() if k != "hashed_password"} if "_id" in user_data: user_data["_id"] = str(user_data["_id"]) + + # Convert avatar data to data URL if present + if "avatar_data" in user_data and "avatar_content_type" in user_data: + avatar_data_url = f"data:{user_data['avatar_content_type']};base64,{user_data['avatar_data']}" + user_data["avatar_url"] = avatar_data_url + # Remove raw data from response + del user_data["avatar_data"] + del user_data["avatar_content_type"] return { "access_token": access_token, @@ -66,6 +72,15 @@ async def read_users_me(current_user = Depends(get_current_user)): # Convert ObjectId to str for JSON serialization if "_id" in current_user: current_user["_id"] = str(current_user["_id"]) + + # Convert avatar data to data URL if present + if "avatar_data" in current_user and "avatar_content_type" in current_user: + avatar_data_url = f"data:{current_user['avatar_content_type']};base64,{current_user['avatar_data']}" + current_user["avatar_url"] = avatar_data_url + # Remove raw data from response + del current_user["avatar_data"] + del current_user["avatar_content_type"] + return current_user @router.put("/profile") @@ -89,6 +104,13 @@ async def update_profile(user_update: UserUpdate, current_user = Depends(get_cur # Remove password if "hashed_password" in updated_user: del updated_user["hashed_password"] + + # Convert avatar data to data URL if present + if "avatar_data" in updated_user and "avatar_content_type" in updated_user: + avatar_data_url = f"data:{updated_user['avatar_content_type']};base64,{updated_user['avatar_data']}" + updated_user["avatar_url"] = avatar_data_url + del updated_user["avatar_data"] + del updated_user["avatar_content_type"] return updated_user except Exception as e: @@ -100,30 +122,43 @@ async def upload_avatar(file: UploadFile = File(...), current_user = Depends(get try: email = current_user["email"] - # Ensure uploads directory exists - current_dir = os.path.dirname(os.path.abspath(__file__)) - storage_dir = os.path.abspath(os.path.join(current_dir, "..", "..", "storage", "avatars")) - os.makedirs(storage_dir, exist_ok=True) + # Validate file type + allowed_types = ["image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp"] + if file.content_type not in allowed_types: + raise HTTPException(status_code=400, detail="Invalid file type. Only JPEG, PNG, GIF, and WebP images are allowed.") - # Generate unique filename - file_extension = os.path.splitext(file.filename)[1] - filename = f"{uuid.uuid4()}{file_extension}" - file_path = os.path.join(storage_dir, filename) + # Read file content + file_content = await file.read() - # Save file - with open(file_path, "wb") as buffer: - shutil.copyfileobj(file.file, buffer) - - # Update user avatar URL - # URL should be relative path served by static - avatar_url = f"/static/avatars/{filename}" + # Validate file size (max 5MB) + max_size = 5 * 1024 * 1024 # 5MB + if len(file_content) > max_size: + raise HTTPException(status_code=400, detail="File too large. Maximum size is 5MB.") - success = await User.update_user(email, {"avatar_url": avatar_url}) + # Convert to base64 + import base64 + avatar_base64 = base64.b64encode(file_content).decode('utf-8') + + # Create data URL for immediate use + avatar_data_url = f"data:{file.content_type};base64,{avatar_base64}" + + # Update user with avatar data in database + update_data = { + "avatar_data": avatar_base64, + "avatar_content_type": file.content_type + } + + success = await User.update_user(email, update_data) if not success: raise HTTPException(status_code=400, detail="Failed to update user avatar") - return {"avatar_url": avatar_url} + return { + "avatar_url": avatar_data_url, + "message": "Avatar uploaded successfully" + } + except HTTPException: + raise except Exception as e: print(f"Avatar upload error: {e}") raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/app/routes/chat.py b/backend/app/routes/chat.py index e44af22..4d0d2fa 100644 --- a/backend/app/routes/chat.py +++ b/backend/app/routes/chat.py @@ -34,3 +34,40 @@ async def get_chat(chat_id: str, current_user = Depends(get_current_user)): return chat except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + +@router.patch("/{chat_id}") +async def rename_chat(chat_id: str, request: dict = Body(...), current_user = Depends(get_current_user)): + try: + user_id = str(current_user["_id"]) + # Verify chat belongs to user + chat = await ChatHistory.get_chat(chat_id, user_id) + if not chat: + raise HTTPException(status_code=404, detail="Chat not found") + + new_title = request.get("title") + if not new_title: + raise HTTPException(status_code=400, detail="Title is required") + + await ChatHistory.update_title(chat_id, new_title) + return {"success": True, "chat_id": chat_id, "title": new_title} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.delete("/{chat_id}") +async def delete_chat(chat_id: str, current_user = Depends(get_current_user)): + try: + user_id = str(current_user["_id"]) + # Verify chat belongs to user + chat = await ChatHistory.get_chat(chat_id, user_id) + if not chat: + raise HTTPException(status_code=404, detail="Chat not found") + + await ChatHistory.delete_chat(chat_id, user_id) + return {"success": True, "chat_id": chat_id} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 700da71..48e8a23 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -9,6 +9,7 @@ import { Sheet, SheetContent, SheetTrigger } from "./components/ui/sheet"; import { DialogTitle, DialogDescription } from "./components/ui/dialog"; import UserMenu from "./components/UserMenu"; import ProfileModal from "./components/ProfileModal"; +import ChatHistoryItem from "./components/ChatHistoryItem"; import { PanelLeft, X, Database, LogOut, SquarePen, Sun, Moon } from "lucide-react"; function App() { @@ -200,6 +201,42 @@ function App() { } }; + const handleRenameChat = async (chatId, newTitle) => { + try { + const res = await fetch(`${API_BASE_URL}/api/chat/${chatId}`, { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ title: newTitle }) + }); + if (res.ok) { + fetchHistory(); // Refresh list + } + } catch (e) { + console.error("Failed to rename chat", e); + } + }; + + const handleDeleteChat = async (chatId) => { + try { + const res = await fetch(`${API_BASE_URL}/api/chat/${chatId}`, { + method: 'DELETE', + headers: { 'Authorization': `Bearer ${token}` } + }); + if (res.ok) { + fetchHistory(); // Refresh list + // If deleting current chat, start new one + if (currentChatId === chatId) { + startNewChat(); + } + } + } catch (e) { + console.error("Failed to delete chat", e); + } + }; + const handleSend = async (text) => { setMessages((prev) => [...prev, { role: "user", content: text }]); setIsTyping(true); @@ -345,7 +382,20 @@ function App() { - + {/* Chat History List */} +