diff --git a/.cursor/rules/docs.mdc b/.cursor/rules/docs.mdc new file mode 100644 index 0000000..4b13faf --- /dev/null +++ b/.cursor/rules/docs.mdc @@ -0,0 +1,11 @@ +--- +description: Documentation rules +alwaysApply: false +--- + +When documenting this project, follow these rules: + +1. For TS, use JSDoc. +2. When something is self-explanatory or is a constant for a HTML element, don't document it. +3. Do NOT change the structure of the code, only add documentation. +4. When filling in the author field, say that you are Cursor. \ No newline at end of file diff --git a/.cursor/rules/ui.mdc b/.cursor/rules/ui.mdc new file mode 100644 index 0000000..6791c5c --- /dev/null +++ b/.cursor/rules/ui.mdc @@ -0,0 +1,8 @@ +--- +alwaysApply: true +--- + +When you work with UI: + +1. Use MDUI components as HTML elements +2. Do NOT dynamically create HTML if it's going to be loaded when the page loads, instead put it statically in the HTML. \ No newline at end of file diff --git a/.gitignore b/.gitignore index f0cb9db..a7c6771 100644 --- a/.gitignore +++ b/.gitignore @@ -376,7 +376,7 @@ pyrightconfig.json # Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option) -instance +data .vite *.db package-lock.json \ No newline at end of file diff --git a/backend/app.py b/backend/app.py index 4476f9a..9b7a6ae 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,7 +1,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from routes import account, messaging +from routes import account, messaging, profile # Инициализация FastAPI app = FastAPI(title="PixelChat") @@ -17,4 +17,5 @@ app.add_middleware( # Routes app.include_router(account.router) -app.include_router(messaging.router) \ No newline at end of file +app.include_router(messaging.router) +app.include_router(profile.router) \ No newline at end of file diff --git a/backend/constants.py b/backend/constants.py index d9a47c3..e3e8bb7 100644 --- a/backend/constants.py +++ b/backend/constants.py @@ -1,4 +1,4 @@ -DATABASE_URL = "sqlite:///./pixelchat.db" -JWT_SECRET_KEY = "pixelchat-jwt-secret" +DATABASE_URL = "sqlite:///./data/database.db" +JWT_SECRET_KEY = "fromchat-jwt-secret" JWT_ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_HOURS = 24 \ No newline at end of file diff --git a/backend/db.py b/backend/db.py index 834a613..1708283 100644 --- a/backend/db.py +++ b/backend/db.py @@ -1,6 +1,10 @@ +import os from sqlalchemy.orm import sessionmaker from sqlalchemy import create_engine from constants import DATABASE_URL +# Ensure data directory exists +os.makedirs("data", exist_ok=True) + engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) \ No newline at end of file diff --git a/backend/dependencies.py b/backend/dependencies.py index 7dd7199..f1b47ab 100644 --- a/backend/dependencies.py +++ b/backend/dependencies.py @@ -19,7 +19,7 @@ def get_db(): def get_current_user( credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(get_db) -): +) -> User: token = credentials.credentials payload = verify_token(token) if not payload: diff --git a/backend/models.py b/backend/models.py index c193247..50271d4 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,5 +1,5 @@ from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey +from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, inspect, text from sqlalchemy.orm import relationship from datetime import datetime from db import engine @@ -15,6 +15,8 @@ class User(Base): id = Column(Integer, primary_key=True, index=True) username = Column(String(50), unique=True, nullable=False, index=True) password_hash = Column(String(200), nullable=False) + profile_picture = Column(String(255), nullable=True) + bio = Column(Text, nullable=True) online = Column(Boolean, default=False) last_seen = Column(DateTime, default=datetime.now) created_at = Column(DateTime, default=datetime.now) @@ -29,8 +31,11 @@ class Message(Base): timestamp = Column(DateTime, default=datetime.now) user_id = Column(Integer, ForeignKey("user.id"), nullable=False) is_read = Column(Boolean, default=False) + reply_to_id = Column(Integer, ForeignKey("message.id"), nullable=True) + is_edited = Column(Boolean, default=False) author = relationship("User", back_populates="messages") + reply_to = relationship("Message", remote_side=[id]) # Pydantic модели @@ -49,6 +54,36 @@ class SendMessageRequest(BaseModel): content: str +class EditMessageRequest(BaseModel): + content: str + + +class ReplyMessageRequest(BaseModel): + content: str + reply_to_id: int + + +class DeleteMessageRequest(BaseModel): + message_id: int + + +class UpdateBioRequest(BaseModel): + bio: str + + +class UserProfileResponse(BaseModel): + id: int + username: str + profile_picture: str | None + bio: str | None + online: bool + last_seen: datetime + created_at: datetime + + class Config: + from_attributes = True + + class MessageResponse(BaseModel): id: int content: str @@ -56,6 +91,7 @@ class MessageResponse(BaseModel): is_author: bool is_read: bool username: str + profile_picture: str | None class Config: from_attributes = True diff --git a/backend/requirements.txt b/backend/requirements.txt index 158eacb..326b790 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,4 +3,6 @@ fastapi[standard]>=0.116.1 pydantic>=2.11.7 sqlalchemy>=2.0.43 bcrypt>=4.3.0 -websockets>=15.0.1 \ No newline at end of file +websockets>=15.0.1 +Pillow>=10.0.0 +python-multipart>=0.0.6 \ No newline at end of file diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index 2e93e1b..220f273 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisco from fastapi.security import HTTPAuthorizationCredentials from sqlalchemy.orm import Session from dependencies import get_current_user, get_db -from models import Message, SendMessageRequest, User +from models import Message, SendMessageRequest, EditMessageRequest, ReplyMessageRequest, User router = APIRouter() logger = logging.getLogger("uvicorn.error") @@ -15,22 +15,19 @@ def convert_message(msg: Message) -> dict: "content": msg.content, "timestamp": msg.timestamp.isoformat(), "is_read": msg.is_read, - "username": msg.author.username + "is_edited": msg.is_edited, + "username": msg.author.username, + "profile_picture": msg.author.profile_picture, + "reply_to": convert_message(msg.reply_to) if msg.reply_to else None } -async def get_messages_inner(db: Session): - messages = db.query(Message).order_by(Message.timestamp.asc()).all() - messages_data = [] - for msg in messages: - messages_data.append(convert_message(msg)) - - return { - "status": "success", - "messages": messages_data - } - -async def send_message_inner(request: SendMessageRequest, current_user: User, db: Session): +@router.post("/send_message") +async def send_message( + request: SendMessageRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): if not request.content.strip(): raise HTTPException( status_code=400, @@ -49,18 +46,94 @@ async def send_message_inner(request: SendMessageRequest, current_user: User, db return {"status": "success", "message": convert_message(new_message)} -@router.post("/send_message") -async def send_message( - request: SendMessageRequest, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - return await send_message_inner(request, current_user, db) - @router.get("/get_messages") async def get_messages(db: Session = Depends(get_db)): - return await get_messages_inner(db) + messages = db.query(Message).order_by(Message.timestamp.asc()).all() + + messages_data = [] + for msg in messages: + messages_data.append(convert_message(msg)) + + return { + "status": "success", + "messages": messages_data + } + + +@router.put("/edit_message/{message_id}") +async def edit_message( + message_id: int, + request: EditMessageRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + message = db.query(Message).filter(Message.id == message_id).first() + + if not message: + raise HTTPException(status_code=404, detail="Message not found") + + if message.user_id != current_user.id: + raise HTTPException(status_code=403, detail="You can only edit your own messages") + + if not request.content.strip(): + raise HTTPException(status_code=400, detail="Message content cannot be empty") + + message.content = request.content.strip() + message.is_edited = True + + db.commit() + db.refresh(message) + + return {"status": "success", "message": convert_message(message)} + + +@router.delete("/delete_message/{message_id}") +async def delete_message( + message_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + message = db.query(Message).filter(Message.id == message_id).first() + + if not message: + raise HTTPException(status_code=404, detail="Message not found") + + if message.user_id != current_user.id: + raise HTTPException(status_code=403, detail="You can only delete your own messages") + + db.delete(message) + db.commit() + + return {"status": "success", "message_id": message_id} + + +@router.post("/reply_message") +async def reply_message( + request: ReplyMessageRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + # Check if the message being replied to exists + original_message = db.query(Message).filter(Message.id == request.reply_to_id).first() + if not original_message: + raise HTTPException(status_code=404, detail="Original message not found") + + if not request.content.strip(): + raise HTTPException(status_code=400, detail="No content provided") + + new_message = Message( + content=request.content.strip(), + user_id=current_user.id, + timestamp=datetime.now(), + reply_to_id=request.reply_to_id + ) + + db.add(new_message) + db.commit() + db.refresh(new_message) + + return {"status": "success", "message": convert_message(new_message)} class MessaggingSocketManager: @@ -95,7 +168,7 @@ class MessaggingSocketManager: if not current_user: raise HTTPException(401) - await websocket.send_json({"type": type, "data": await get_messages_inner(current_user, db)}) + await websocket.send_json({"type": type, "data": await get_messages(current_user, db)}) except HTTPException as e: await self.send_error(websocket, type, e) elif type == "sendMessage": @@ -106,7 +179,57 @@ class MessaggingSocketManager: request: SendMessageRequest = SendMessageRequest.model_validate(data["data"]) - response = await send_message_inner(request, current_user, db) + response = await send_message(request, current_user, db) + await self.broadcast({ + "type": "newMessage", + "data": response["message"] + }) + + await websocket.send_json({"type": type, "data": response}) + except HTTPException as e: + await self.send_error(websocket, type, e) + elif type == "editMessage": + try: + current_user = get_current_user_inner() + if not current_user: + raise HTTPException(401) + + message_id = data["data"]["message_id"] + request: EditMessageRequest = EditMessageRequest.model_validate(data["data"]) + + response = await edit_message(message_id, request, current_user, db) + await self.broadcast({ + "type": "messageEdited", + "data": response["message"] + }) + + await websocket.send_json({"type": type, "data": response}) + except HTTPException as e: + await self.send_error(websocket, type, e) + elif type == "deleteMessage": + try: + current_user = get_current_user_inner() + if not current_user: + raise HTTPException(401) + + message_id = data["data"]["message_id"] + response = await delete_message(message_id, current_user, db) + await self.broadcast({ + "type": "messageDeleted", + "data": {"message_id": message_id} + }) + + await websocket.send_json({"type": type, "data": response}) + except HTTPException as e: + await self.send_error(websocket, type, e) + elif type == "replyMessage": + try: + current_user = get_current_user_inner() + if not current_user: + raise HTTPException(401) + + request: ReplyMessageRequest = ReplyMessageRequest.model_validate(data["data"]) + response = await reply_message(request, current_user, db) await self.broadcast({ "type": "newMessage", "data": response["message"] diff --git a/backend/routes/profile.py b/backend/routes/profile.py new file mode 100644 index 0000000..c1fa26f --- /dev/null +++ b/backend/routes/profile.py @@ -0,0 +1,144 @@ +from pathlib import Path +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File +from sqlalchemy.orm import Session +from PIL import Image +import os +import uuid +import io + +from dependencies import get_db, get_current_user +from models import User, UpdateBioRequest, UserProfileResponse + +router = APIRouter() + +# Create uploads directory if it doesn't exist +PROFILE_PICTURES_DIR = Path("data/uploads/pfp") + +os.makedirs(PROFILE_PICTURES_DIR, exist_ok=True) + +@router.post("/upload-profile-picture") +async def upload_profile_picture( + profile_picture: UploadFile = File(...), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + Upload and process a profile picture + """ + # Validate file type + if not profile_picture.content_type.startswith('image/'): + raise HTTPException(status_code=400, detail="File must be an image") + + # Validate file size (max 5MB) + if profile_picture.size > 5 * 1024 * 1024: + raise HTTPException(status_code=400, detail="File size must be less than 5MB") + + try: + # Read and process the image + image_data = await profile_picture.read() + + # Open image with PIL + image = Image.open(io.BytesIO(image_data)) + + # Convert to RGB if necessary + if image.mode != 'RGB': + image = image.convert('RGB') + + # Resize to a reasonable size (200x200) + image.thumbnail((200, 200), Image.Resampling.LANCZOS) + + # Generate unique filename + filename = f"{current_user.id}_{uuid.uuid4().hex}.jpg" + filepath = os.path.join(PROFILE_PICTURES_DIR, filename) + + # Save the processed image + image.save(filepath, 'JPEG', quality=85) + + # Update user's profile picture in database + profile_picture_url = f"/api/profile-picture/{filename}" + current_user.profile_picture = profile_picture_url + db.commit() + + return { + "message": "Profile picture uploaded successfully", + "profile_picture_url": profile_picture_url + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error processing image: {str(e)}") + +@router.get("/profile-picture/{filename}") +async def get_profile_picture(filename: str): + """ + Serve profile picture files + """ + filepath = os.path.join(PROFILE_PICTURES_DIR, filename) + + if not os.path.exists(filepath): + raise HTTPException(status_code=404, detail="Profile picture not found") + + from fastapi.responses import FileResponse + return FileResponse(filepath, media_type="image/jpeg") + +@router.get("/user/profile") +async def get_user_profile( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + Get current user's profile information + """ + return { + "id": current_user.id, + "username": current_user.username, + "profile_picture": current_user.profile_picture, + "bio": current_user.bio, + "online": current_user.online, + "last_seen": current_user.last_seen, + "created_at": current_user.created_at + } + + +@router.put("/user/bio") +async def update_user_bio( + request: UpdateBioRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + Update current user's bio + """ + if len(request.bio) > 500: # Limit bio to 500 characters + raise HTTPException(status_code=400, detail="Bio must be 500 characters or less") + + current_user.bio = request.bio.strip() + db.commit() + + return { + "message": "Bio updated successfully", + "bio": current_user.bio + } + + +@router.get("/user/{username}") +async def get_user_by_username( + username: str, + db: Session = Depends(get_db) +): + """ + Get user profile by username + """ + user = db.query(User).filter(User.username == username).first() + + if not user: + raise HTTPException(status_code=404, detail="User not found") + + return UserProfileResponse( + id=user.id, + username=user.username, + profile_picture=user.profile_picture, + bio=user.bio, + online=user.online, + last_seen=user.last_seen, + created_at=user.created_at + ) diff --git a/frontend/index.html b/frontend/index.html index d63394a..5f5a6e8 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -21,7 +21,7 @@
- -
Loading...
- + + +
@@ -137,10 +139,10 @@ - + - + @@ -150,24 +152,25 @@
- +
-
+
Avatar
-

Общий чат

+

Общий чат

- Онлайн + + Онлайн

- Свернуть чат + Свернуть чат
@@ -189,15 +192,217 @@
+ -

- Скоро будет... -

- -

- Закрыть -

+
+
+
+ Ваше фото + + +
+ +
+ + + +
+ Сохранить изменения + Закрыть +
+
+
+ + + +
+
+

Обрезать фото профиля

+ +
+
+
+
+
+ Отмена + Сохранить +
+
+
+ + +
+
+
+ + Настройки +
+
+ + Уведомления + Внешний вид + Безопасность + Язык + Хранилище + Помощь + О приложении + +
+
+

Уведомления

+ Новые сообщения + Звуковые уведомления + Уведомления о статусе + Email уведомления +
+ +
+

Внешний вид

+ + Тёмная + Светлая + Авто + + + Маленький + Средний + Большой + +
+ +
+

Безопасность

+ Изменить пароль + Двухфакторная аутентификация + Автоматический выход +
+ +
+

Язык

+ + Русский + English + Español + +
+ +
+

Хранилище

+

Использовано: 2.5 ГБ из 10 ГБ

+ + Очистить кэш +
+ +
+

Помощь

+ Руководство пользователя + Связаться с поддержкой + FAQ +
+ +
+

О приложении

+

Версия: 1.0.0

+

© 2024 From Chat. Все права защищены.

+ Политика конфиденциальности + Условия использования +
+
+
+
+
+
+ +
+
+ reply + Reply +
+
+ edit + Edit +
+
+ delete + Delete +
+
+ + +
+

Edit Message

+ + +
+ Cancel + Save +
+
+
+ + +
+

Reply to Message

+
+ + +
+ Cancel + Send Reply +
+
+
+ + +
+
+ Profile Picture +
+
+
+

+
+
+
+ +
+
+
+
+ Member since: + +
+
+ Last seen: + +
+
+
+
+
+ - \ No newline at end of file + \ No newline at end of file diff --git a/frontend/src/auth.ts b/frontend/src/auth.ts index d869a72..98a01ec 100644 --- a/frontend/src/auth.ts +++ b/frontend/src/auth.ts @@ -1,55 +1,108 @@ +/** + * @fileoverview Authentication system implementation + * @description Handles user authentication, registration, and session management + * @author Cursor + * @version 1.0.0 + */ + import { loadMessages } from "./chat"; +import { initializeProfile } from "./profile"; import type { Headers, ErrorResponse, User, LoginResponse, LoginRequest, RegisterRequest } from "./types"; import { API_BASE_URL } from "./config"; -// Authentication and navigation handling +/** + * Current authenticated user information + * @type {User | null} + */ export let currentUser: User | null = null; + +/** + * JWT authentication token + * @type {string | null} + */ export let authToken: string | null = null; +/** + * Generates authentication headers for API requests + * @param {boolean} json - Whether to include JSON content type header + * @returns {Headers} Headers object with authentication and content type + * @function getAuthHeaders + * @example + * const headers = getAuthHeaders(); + * fetch('/api/endpoint', { headers }); + */ +export function getAuthHeaders(json: boolean = true): Headers { + const headers: Headers = {}; + + if (json) { + headers["Content-Type"] = "application/json"; + } -// Helper function to get auth headers -export function getAuthHeaders(): Headers { - const headers: Headers = { - 'Content-Type': 'application/json', - }; if (authToken) { headers['Authorization'] = `Bearer ${authToken}`; } return headers; } -// Show login form -export function showLogin() { +/** + * Shows the login form and hides other interfaces + * @function showLogin + * @example + * showLogin(); + */ +export function showLogin(): void { document.getElementById('login-form')!.style.display = 'flex'; document.getElementById('register-form')!.style.display = 'none'; document.getElementById('chat-interface')!.style.display = 'none'; clearAlerts(); } -// Show register form -export function showRegister() { +/** + * Shows the registration form and hides other interfaces + * @function showRegister + * @example + * showRegister(); + */ +export function showRegister(): void { document.getElementById('login-form')!.style.display = 'none'; document.getElementById('register-form')!.style.display = 'flex'; document.getElementById('chat-interface')!.style.display = 'none'; clearAlerts(); } -// Show chat interface -export function showChat() { +/** + * Shows the chat interface and hides authentication forms + * @function showChat + * @example + * showChat(); + */ +export function showChat(): void { document.getElementById('login-form')!.style.display = 'none'; document.getElementById('register-form')!.style.display = 'none'; document.getElementById('chat-interface')!.style.display = 'block'; loadMessages(); } -// Clear all alerts -export function clearAlerts() { +/** + * Clears all alert messages from authentication forms + * @function clearAlerts + * @private + */ +export function clearAlerts(): void { document.getElementById('login-alerts')!.innerHTML = ''; document.getElementById('register-alerts')!.innerHTML = ''; } -// Show alert message -export function showAlert(containerId: string, message: string, type: "success" | "danger" = 'danger') { +/** + * Shows an alert message in the specified container + * @param {string} containerId - ID of the container to show the alert in + * @param {string} message - Alert message to display + * @param {'success' | 'danger'} type - Type of alert (success or danger) + * @function showAlert + * @example + * showAlert('login-alerts', 'Login successful!', 'success'); + */ +export function showAlert(containerId: string, message: string, type: "success" | "danger" = 'danger'): void { const container = document.getElementById(containerId)!; const alertDiv = document.createElement('div'); alertDiv.className = `alert alert-${type}`; @@ -57,8 +110,14 @@ export function showAlert(containerId: string, message: string, type: "success" container.appendChild(alertDiv); } -// Handle login form submission -document.getElementById('login-form-element')!.addEventListener('submit', async (e) => { +/** + * Handles login form submission + * @async + * @function handleLogin + * @param {Event} e - Form submission event + * @private + */ +async function handleLogin(e: Event): Promise { e.preventDefault(); const usernameElement = document.getElementById('login-username') as HTMLInputElement; @@ -93,6 +152,7 @@ document.getElementById('login-form-element')!.addEventListener('submit', async currentUser = data.user; showChat(); loadMessages(); // Start loading messages + initializeProfile(); // Initialize profile after login } else { const data: ErrorResponse = await response.json(); showAlert('login-alerts', data.message || 'Неверное имя пользователя или пароль', 'danger'); @@ -100,10 +160,16 @@ document.getElementById('login-form-element')!.addEventListener('submit', async } catch (error) { showAlert('login-alerts', 'Ошибка соединения с сервером', 'danger'); } -}); +} -// Handle register form submission -document.getElementById('register-form-element')!.addEventListener('submit', async (e) => { +/** + * Handles registration form submission + * @async + * @function handleRegister + * @param {Event} e - Form submission event + * @private + */ +async function handleRegister(e: Event): Promise { e.preventDefault(); const usernameElement = document.getElementById('register-username') as HTMLInputElement; @@ -162,10 +228,16 @@ document.getElementById('register-form-element')!.addEventListener('submit', asy } catch (error) { showAlert('register-alerts', 'Ошибка соединения с сервером', 'danger'); } -}); +} -// Handle logout -export async function logout() { +/** + * Logs out the current user and clears session data + * @async + * @function logout + * @example + * await logout(); + */ +export async function logout(): Promise { try { await fetch(`${API_BASE_URL}/logout`, { method: 'GET', @@ -181,15 +253,50 @@ export async function logout() { clearAlerts(); } -// Load chat interface -export function loadChat() { +/** + * Loads the chat interface and initializes messaging + * @function loadChat + * @example + * loadChat(); + */ +export function loadChat(): void { showChat(); loadMessages(); } -// Check authentication status on page load -export async function checkAuthStatus() { +/** + * Checks authentication status on page load + * @async + * @function checkAuthStatus + * @example + * await checkAuthStatus(); + */ +export async function checkAuthStatus(): Promise { // For JWT, we don't have a persistent token on page load // So we'll just show the login form showLogin(); } + +/** + * Sets up authentication form event listeners + * @function setupAuthForms + * @private + */ +function setupAuthForms(): void { + document.getElementById('login-form-element')!.addEventListener('submit', handleLogin); + document.getElementById('register-form-element')!.addEventListener('submit', handleRegister); +} + +/** + * Initializes links + * @function setupLinks + * @private + */ +function setupLinks(): void { + document.getElementById("login-link")!.addEventListener("click", showLogin); + document.getElementById("register-link")!.addEventListener("click", showRegister); +} + +// Initialize authentication forms +setupAuthForms(); +setupLinks(); \ No newline at end of file diff --git a/frontend/src/chat.ts b/frontend/src/chat.ts index 1c71fc8..834a3a7 100644 --- a/frontend/src/chat.ts +++ b/frontend/src/chat.ts @@ -1,11 +1,27 @@ +/** + * @fileoverview Chat functionality and message management + * @description Handles message display, loading, sending, and real-time updates + * @author Cursor + * @version 1.0.0 + */ + import { getAuthHeaders, currentUser, authToken } from "./auth"; import { API_BASE_URL } from "./config"; import { websocket } from "./websocket"; import type { Message, Messages, WebSocketMessage } from "./types"; -import { formatTime } from "./utils"; +import { formatTime } from "./utils/utils"; +import { show as showContextMenu } from "./message-context-menu"; +import { show as showUserProfileDialog } from "./user-profile-dialog"; - -export function addMessage(message: Message, isAuthor: boolean) { +/** + * Adds a new message to the chat interface + * @param {Message} message - Message object to display + * @param {boolean} isAuthor - Whether the current user is the message author + * @function addMessage + * @example + * addMessage(messageData, messageData.username === currentUser.username); + */ +export function addMessage(message: Message, isAuthor: boolean): void { const messagesContainer = document.querySelector('.chat-messages') as HTMLElement; const messageDiv = document.createElement('div'); messageDiv.classList.add("message"); @@ -19,20 +35,68 @@ export function addMessage(message: Message, isAuthor: boolean) { const messageInner = document.createElement('div'); messageInner.classList.add('message-inner'); + // Add profile picture for received messages + if (!isAuthor) { + const profilePicDiv = document.createElement('div'); + profilePicDiv.classList.add('message-profile-pic'); + + const profileImg = document.createElement('img'); + profileImg.src = message.profile_picture || './src/images/default-avatar.png'; + profileImg.alt = message.username; + profileImg.onerror = () => { + profileImg.src = './src/images/default-avatar.png'; + }; + + // Add click handler to profile picture + profileImg.style.cursor = 'pointer'; + profileImg.addEventListener('click', () => { + showUserProfileDialog(message.username); + }); + + profilePicDiv.appendChild(profileImg); + messageDiv.appendChild(profilePicDiv); + } + if (!isAuthor) { const usernameDiv = document.createElement('div'); usernameDiv.classList.add('message-username'); usernameDiv.textContent = message.username; + + // Add click handler to username + usernameDiv.style.cursor = 'pointer'; + usernameDiv.addEventListener('click', () => { + showUserProfileDialog(message.username); + }); + messageInner.appendChild(usernameDiv); } + // Add reply preview if this is a reply + if (message.reply_to) { + const replyDiv = document.createElement('div'); + replyDiv.classList.add('message-reply'); + replyDiv.innerHTML = ` +
+ ${message.reply_to.username} + ${message.reply_to.content} +
+ `; + messageInner.appendChild(replyDiv); + } + const contentDiv = document.createElement('div'); + contentDiv.classList.add('message-content'); contentDiv.textContent = message.content; messageInner.appendChild(contentDiv); const timeDiv = document.createElement('div'); timeDiv.classList.add('message-time'); - timeDiv.textContent = formatTime(message.timestamp); + + let timeText = formatTime(message.timestamp); + if (message.is_edited) { + timeText += ' (edited)'; + } + timeDiv.textContent = timeText; if (isAuthor && message.is_read) { const checkIcon = document.createElement('span'); @@ -44,11 +108,23 @@ export function addMessage(message: Message, isAuthor: boolean) { messageDiv.appendChild(messageInner); messagesContainer.appendChild(messageDiv); + // Add right-click context menu + messageDiv.addEventListener('contextmenu', (e) => { + e.preventDefault(); + showContextMenu(message, e.clientX, e.clientY); + }); + // Прокрутка к новому сообщению messagesContainer.scrollTop = messagesContainer.scrollHeight; } -export function loadMessages() { +/** + * Loads chat messages from the server + * @function loadMessages + * @example + * loadMessages(); + */ +export function loadMessages(): void { fetch(`${API_BASE_URL}/get_messages`, { headers: getAuthHeaders() }) @@ -73,7 +149,13 @@ export function loadMessages() { }); } -export function sendMessage() { +/** + * Sends a message via WebSocket + * @function sendMessage + * @example + * sendMessage(); + */ +export function sendMessage(): void { const input = document.querySelector('.message-input') as HTMLInputElement; const message = input.value.trim(); @@ -108,4 +190,67 @@ export function sendMessage() { document.getElementById('message-form')!.addEventListener('submit', (e) => { e.preventDefault(); sendMessage(); -}); \ No newline at end of file +}); + +/** + * Updates an existing message in the chat interface + * @param {Message} message - Updated message object + * @function updateMessage + */ +export function updateMessage(message: Message): void { + const messageElement = document.querySelector(`[data-id="${message.id}"]`) as HTMLElement; + if (!messageElement) return; + + const contentDiv = messageElement.querySelector('.message-content') as HTMLElement; + const timeDiv = messageElement.querySelector('.message-time') as HTMLElement; + + if (contentDiv) { + contentDiv.textContent = message.content; + } + + if (timeDiv) { + let timeText = formatTime(message.timestamp); + if (message.is_edited) { + timeText += ' (edited)'; + } + timeDiv.textContent = timeText; + } +} + +/** + * Removes a message from the chat interface + * @param {number} messageId - ID of the message to remove + * @function removeMessage + */ +export function removeMessage(messageId: number): void { + const messageElement = document.querySelector(`[data-id="${messageId}"]`) as HTMLElement; + if (messageElement) { + messageElement.remove(); + } +} + +/** + * Handles WebSocket message updates + * @param {WebSocketMessage} response - WebSocket response + * @function handleWebSocketMessage + */ +export function handleWebSocketMessage(response: WebSocketMessage): void { + switch (response.type) { + case 'messageEdited': + if (response.data) { + updateMessage(response.data); + } + break; + case 'messageDeleted': + if (response.data && response.data.message_id) { + removeMessage(response.data.message_id); + } + break; + case 'newMessage': + if (response.data) { + const isAuthor = response.data.username === currentUser?.username; + addMessage(response.data, isAuthor); + } + break; + } +} \ No newline at end of file diff --git a/frontend/src/config.ts b/frontend/src/config.ts index 5de6c3e..f46c8a0 100644 --- a/frontend/src/config.ts +++ b/frontend/src/config.ts @@ -1,3 +1,27 @@ +/** + * @fileoverview Application configuration constants + * @description Contains all configuration values used throughout the application + * @author Cursor + * @version 1.0.0 + */ + +/** + * Base API endpoint for all backend requests + * @type {string} + * @constant + */ export const API_BASE_URL: string = '/api'; + +/** + * Full API URL including hostname and port for WebSocket connections + * @type {string} + * @constant + */ export const API_FULL_BASE_URL: string = `${location.hostname}:8301/api`; + +/** + * Application name displayed in UI and document title + * @type {string} + * @constant + */ export const PRODUCT_NAME: string = "FromChat"; \ No newline at end of file diff --git a/frontend/src/css/_chat.scss b/frontend/src/css/_chat.scss index fe4ecdf..ccdb043 100644 --- a/frontend/src/css/_chat.scss +++ b/frontend/src/css/_chat.scss @@ -7,7 +7,6 @@ background-color: $color-dark-surface-container; color: white; padding: 16px 16px; - position: static; justify-content: end; width: fit-content; z-index: 1000; @@ -70,7 +69,7 @@ background: $color-dark-surface-container; display: flex; align-items: center; - box-shadow: black 0px 0px 20px; + box-shadow: black 0 0 20px; .chat-header-avatar { width: 45px; @@ -89,8 +88,7 @@ h4 { font-size: 1.1rem; - margin: 0; - margin-bottom: 0.2rem; + margin: 0 0 0.2rem; } p { @@ -151,6 +149,29 @@ max-width: 70%; position: relative; width: max-content; + display: flex; + align-items: flex-end; + gap: 8px; + + .message-profile-pic { + width: 32px; + height: 32px; + flex-shrink: 0; + margin-bottom: 4px; + + img { + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; + transition: transform 0.2s ease, box-shadow 0.2s ease; + + &:hover { + transform: scale(1.1); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + } + } + } .message-inner { padding: 0.8rem 1rem; @@ -158,6 +179,40 @@ position: relative; word-wrap: break-word; + .message-content { + word-wrap: break-word; + margin-bottom: 0.3rem; + } + + .message-reply { + background-color: rgba(255, 255, 255, 0.1); + border-radius: 8px; + padding: 0.5rem; + margin-bottom: 0.5rem; + border-left: 3px solid $color-dark-primary; + + .reply-content { + display: flex; + flex-direction: column; + gap: 0.2rem; + + .reply-username { + font-weight: 600; + font-size: 0.8rem; + color: $color-dark-primary; + } + + .reply-text { + font-size: 0.85rem; + color: $color-dark-on-surface-variant; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 200px; + } + } + } + .message-time { font-size: 0.7rem; color: $color-dark-on-surface-variant; @@ -174,6 +229,7 @@ &.sent { margin-left: auto; + flex-direction: row-reverse; .message-inner { background-color: $color-dark-primary-container; @@ -191,6 +247,12 @@ font-weight: 600; margin-bottom: 0.3rem; font-size: 0.9rem; + transition: color 0.2s ease; + + &:hover { + color: $color-dark-primary; + text-decoration: underline; + } } } @@ -255,4 +317,79 @@ } } } -} \ No newline at end of file +} + +// Message Context Menu +#message-context-menu { + position: fixed; + background-color: $color-dark-surface-container; + border-radius: 8px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + padding: 0.5rem 0; + z-index: 1000; + display: none; + min-width: 150px; + max-width: 200px; + white-space: nowrap; + + .context-menu-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + cursor: pointer; + color: $color-dark-on-surface; + transition: background-color 0.2s ease; + font-size: 0.9rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &:hover { + background-color: rgba(255, 255, 255, 0.1); + } + + .material-symbols { + font-size: 1.1rem; + flex-shrink: 0; + } + } +} + +// Dialog content styles +.dialog-content { + padding: 1.5rem; + + h3 { + margin: 0 0 1rem 0; + color: $color-dark-on-surface; + font-size: 1.2rem; + } + + .dialog-actions { + display: flex; + gap: 0.75rem; + justify-content: flex-end; + margin-top: 1.5rem; + } +} + +// Reply preview styles +.reply-preview { + background-color: $color-dark-surface; + border-radius: 8px; + padding: 0.75rem; + margin-bottom: 1rem; + border-left: 3px solid $color-dark-primary; + + .reply-preview-content { + color: $color-dark-on-surface-variant; + font-size: 0.9rem; + line-height: 1.4; + + strong { + color: $color-dark-primary; + } + } +} + diff --git a/frontend/src/css/_panelchat.scss b/frontend/src/css/_panelchat.scss index d9811d2..a151459 100644 --- a/frontend/src/css/_panelchat.scss +++ b/frontend/src/css/_panelchat.scss @@ -89,7 +89,6 @@ img { $size: 45px; - display: flex; border-radius: 50%; width: $size; diff --git a/frontend/src/css/_profile.scss b/frontend/src/css/_profile.scss new file mode 100644 index 0000000..518c983 --- /dev/null +++ b/frontend/src/css/_profile.scss @@ -0,0 +1,239 @@ +@use "common/colors" as *; +@use "common/material" as *; + +#profile-dialog .content { + display: flex; + flex-direction: column; + gap: 24px; + min-width: 400px; + + .header-top { + display: flex; + flex-direction: row; + align-items: center; + gap: 16px; + position: relative; + padding-bottom: 16px; + border-bottom: 1px solid $color-dark-outline; + + .profile-picture-container { + position: relative; + $size: 70px; + width: $size; + height: $size; + flex-shrink: 0; + + #profile-picture { + width: $size; + height: $size; + border-radius: 50%; + object-fit: cover; + } + + .upload-overlay { + position: absolute; + bottom: 0; + right: 0; + width: 28px; + height: 28px; + cursor: pointer; + } + } + + mdui-text-field { + flex: 1; + } + } + + #profile-form { + display: flex; + flex-direction: column; + gap: 16px; + + mdui-text-field { + width: 100%; + } + + .dialog-actions { + display: flex; + gap: 12px; + padding-top: 16px; + border-top: 1px solid $color-dark-outline; + + > * { + flex: 1; + } + } + } +} + +// User profile dialog content styles +#user-profile-dialog .content { + display: flex; + gap: 1.5rem; + + .profile-picture-section { + flex-shrink: 0; + + .profile-picture { + width: 80px; + height: 80px; + border-radius: 50%; + object-fit: cover; + border: 2px solid $color-dark-outline; + } + } + + .profile-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 1rem; + + .username-section { + display: flex; + align-items: center; + gap: 0.75rem; + + .username { + margin: 0; + color: $color-dark-on-surface; + font-size: 1.1rem; + font-weight: 600; + } + + .online-status { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; + padding: 0.25rem 0.5rem; + border-radius: 12px; + font-weight: 500; + + &.online { + color: $success; + background-color: rgba(76, 175, 80, 0.1); + + .online-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: $success; + } + } + + &.offline { + color: $color-dark-on-surface-variant; + background-color: rgba(255, 255, 255, 0.05); + + .offline-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: $color-dark-on-surface-variant; + } + } + } + } + + .bio-section { + label { + display: block; + color: $color-dark-on-surface-variant; + font-size: 0.85rem; + font-weight: 500; + margin-bottom: 0.5rem; + } + + .bio-display { + color: $color-dark-on-surface; + font-size: 0.9rem; + line-height: 1.4; + padding: 0.75rem; + background-color: $color-dark-surface; + border-radius: 8px; + border: 1px solid $color-dark-outline; + min-height: 60px; + } + + .bio-actions { + display: flex; + gap: 0.5rem; + margin-top: 0.5rem; + } + } + + .profile-stats { + display: flex; + flex-direction: column; + gap: 0.5rem; + + .stat { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0; + + .stat-label { + color: $color-dark-on-surface-variant; + font-size: 0.85rem; + } + + .stat-value { + color: $color-dark-on-surface; + font-size: 0.85rem; + font-weight: 500; + } + } + } + } +} + +// Cropper Dialog Styles +#cropper-dialog { + .cropper-dialog-content { + display: flex; + flex-direction: column; + gap: 16px; + min-width: 500px; + max-width: 600px; + } + + .cropper-header { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 16px; + border-bottom: 1px solid $color-dark-outline; + + h3 { + margin: 0; + color: $color-dark-on-surface; + } + } + + .cropper-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 400px; + background: $color-dark-surface-container; + border-radius: 8px; + overflow: hidden; + + #cropper-area { + width: 100%; + height: 100%; + min-height: 400px; + } + } + + .cropper-actions { + display: flex; + gap: 12px; + justify-content: flex-end; + padding-top: 16px; + border-top: 1px solid $color-dark-outline; + } +} \ No newline at end of file diff --git a/frontend/src/css/_settings.scss b/frontend/src/css/_settings.scss new file mode 100644 index 0000000..4607769 --- /dev/null +++ b/frontend/src/css/_settings.scss @@ -0,0 +1,99 @@ +@use "common/colors" as *; +@use "common/material" as *; + +#settings-dialog { + .fullscreen-wrapper { + display: flex; + align-items: center; + justify-content: center; + height: calc(100vh - (1.5rem * 2)); + width: 100%; + position: relative; + + #settings-dialog-inner { + max-width: 1200px; + max-height: 1000px; + width: 100%; + height: 100%; + + .header { + display: flex; + flex-direction: row; + gap: 10px; + margin-bottom: 16px; + } + + #settings-menu { + display: flex; + flex-direction: row; + gap: 16px; + + mdui-list { + max-width: 280px; + padding-right: 16px; + overflow-y: auto; + } + + .screen { + flex: 1; + overflow-y: auto; + position: relative; + + .settings-panel { + display: flex; + flex-direction: column; + gap: 16px; + opacity: 0; + visibility: hidden; + transform: translateY(20px); + transition: opacity 0.3s ease, transform 0.3s ease, visibility 0.3s ease; + position: absolute; + top: 0; + left: 0; + width: 100%; + + &.active { + opacity: 1; + visibility: visible; + transform: translateY(0); + position: relative; + } + + h3 { + margin: 0 0 16px 0; + color: $color-dark-on-surface; + } + + mdui-text-field, + mdui-select, + mdui-switch, + mdui-button { + margin-bottom: 8px; + } + + mdui-switch { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 0; + border-bottom: 1px solid $color-dark-outline; + + &:last-child { + border-bottom: none; + } + } + + p { + margin: 8px 0; + color: $color-dark-on-surface-variant; + } + + mdui-linear-progress { + margin: 16px 0; + } + } + } + } + } + } +} \ No newline at end of file diff --git a/frontend/src/css/common/_components.scss b/frontend/src/css/common/_components.scss index 0307fab..982ba92 100644 --- a/frontend/src/css/common/_components.scss +++ b/frontend/src/css/common/_components.scss @@ -4,10 +4,6 @@ text-align: center; } -.mt-3 { - margin-top: 1rem; -} - .alert { padding: 0.8rem 1rem; border-radius: 6px; diff --git a/frontend/src/css/style.scss b/frontend/src/css/style.scss index b5aeb78..3906aeb 100644 --- a/frontend/src/css/style.scss +++ b/frontend/src/css/style.scss @@ -1,5 +1,7 @@ @use "auth"; @use "chat"; +@use "profile"; +@use "settings"; @use "panelchat"; @use "common/animations"; @use "common/components"; @@ -25,6 +27,7 @@ body { } mdui-dialog { + > *:first-child { margin-block-start: 0; } diff --git a/frontend/src/init.ts b/frontend/src/init.ts index 87305c2..47d1fe1 100644 --- a/frontend/src/init.ts +++ b/frontend/src/init.ts @@ -1,7 +1,13 @@ +/** + * @fileoverview Application initialization logic + * @description Handles initial application setup and state + * @author FromChat Team + * @version 1.0.0 + */ + import { showLogin } from "./auth"; import { PRODUCT_NAME } from "./config"; showLogin(); - document.getElementById("productname")!.textContent = PRODUCT_NAME; document.title = PRODUCT_NAME; \ No newline at end of file diff --git a/frontend/src/leftpanel.ts b/frontend/src/leftpanel.ts index 9115422..62806b1 100644 --- a/frontend/src/leftpanel.ts +++ b/frontend/src/leftpanel.ts @@ -1,36 +1,70 @@ +/** + * @fileoverview Left panel UI controls and interactions + * @description Handles chat collapse/expand, chat switching, and profile dialog + * @author Cursor + * @version 1.0.0 + */ + import type { Dialog } from "mdui/components/dialog"; +import { loadProfilePicture } from "./profile/upload"; // сварачивание и разворачивание чата -const but = document.getElementById('chat-recrol')!; -const but_list1 = document.getElementById('chat1but')!; -const but_list2 = document.getElementById('chat1but2')!; -const cont1 = document.getElementById('conteinerchat')!; -const namechat = document.getElementById('namechat')!; - -but.addEventListener('click', () => { - but.style.display = 'none'; - cont1.style.display = 'none'; -}); -but_list1.addEventListener('click', () => { - but.style.display = 'flex'; - cont1.style.display = 'flex'; - namechat.textContent = 'общий чат'; -}); -but_list2.addEventListener('click', () => { - but.style.display = 'flex'; - cont1.style.display = 'flex'; - namechat.textContent = 'общий чат 2'; -}); - -// открытие профиля -const butprofile = document.getElementById('profbut')!; +const chatCollapseBtn = document.getElementById('hide-chat')!; +const chat1 = document.getElementById('chat-list-chat-1')!; +const chat2 = document.getElementById('chat-list-chat-2')!; +const chatInner = document.getElementById('chat-inner')!; +const chatName = document.getElementById('chat-name')!; +const profileButton = document.getElementById('profile-open')!; const dialog = document.getElementById("profile-dialog") as Dialog; const dialogClose = document.getElementById("profile-dialog-close")!; -butprofile.addEventListener('click', () => { - dialog.open = true; -}); +/** + * Sets up chat collapse functionality + * @function setupChatCollapse + * @private + */ +function setupChatCollapse(): void { + chatCollapseBtn.addEventListener('click', () => { + chatCollapseBtn.style.display = 'none'; + chatInner.style.display = 'none'; + }); +} -dialogClose.addEventListener("click", () => { - dialog.open = false; -}); \ No newline at end of file +/** + * Sets up chat switching functionality + * @function setupChatSwitching + * @private + */ +function setupChatSwitching(): void { + chat1.addEventListener('click', () => { + chatCollapseBtn.style.display = 'flex'; + chatInner.style.display = 'flex'; + chatName.textContent = 'общий чат'; + }); + + chat2.addEventListener('click', () => { + chatCollapseBtn.style.display = 'flex'; + chatInner.style.display = 'flex'; + chatName.textContent = 'общий чат 2'; + }); +} + +/** + * Sets up profile dialog functionality + * @function setupProfileDialog + * @private + */ +function setupProfileDialog(): void { + profileButton.addEventListener('click', () => { + dialog.open = true; + loadProfilePicture(); + }); + + dialogClose.addEventListener("click", () => { + dialog.open = false; + }); +} + +setupChatCollapse(); +setupChatSwitching(); +setupProfileDialog(); \ No newline at end of file diff --git a/frontend/src/links.ts b/frontend/src/links.ts deleted file mode 100644 index b051b28..0000000 --- a/frontend/src/links.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { /* loadChat, */ /* logout, */ showLogin, showRegister } from "./auth"; - -const login = document.getElementById("login-link")!; -const register = document.getElementById("register-link")!; -// const chat = document.getElementById("chat-link")!; -// const logoutLink = document.getElementById("logout-link")!; - -login.addEventListener("click", showLogin); -register.addEventListener("click", showRegister); -// chat.addEventListener("click", loadChat); -// logoutLink.addEventListener("click", logout); diff --git a/frontend/src/main.ts b/frontend/src/main.ts index ce8bbf8..3b78d81 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -1,8 +1,18 @@ +/** + * @fileoverview Application entry point for FromChat frontend + * @description Main module that initializes all required components and styles + * @author Cursor + * @version 1.0.0 + */ + import './css/style.scss'; import "mdui/mdui.css"; -import "./links"; -import "./material"; +import "./utils/material"; import "./chat"; +import "./settings"; import "./leftpanel"; -import "./init"; \ No newline at end of file +import "./init"; +import "./profile"; +import "./message-context-menu"; +import "./user-profile-dialog"; \ No newline at end of file diff --git a/frontend/src/message-context-menu.ts b/frontend/src/message-context-menu.ts new file mode 100644 index 0000000..aece9f8 --- /dev/null +++ b/frontend/src/message-context-menu.ts @@ -0,0 +1,348 @@ +/** + * @fileoverview Message context menu functionality + * @description Handles right-click context menu for message actions (edit, delete, reply) + * @author Cursor + * @version 1.0.0 + */ + +import { currentUser, authToken } from "./auth"; +import { websocket } from "./websocket"; +import type { Message, WebSocketMessage } from "./types"; +import { showSuccess, showError } from "./utils/notification"; + + +let menu = document.getElementById("message-context-menu")!; +let editDialog = document.getElementById("edit-message-dialog"); +let replyDialog = document.getElementById("reply-message-dialog"); +let currentMessage: Message | null = null; + +function init() { + bindEvents(); +} + +/** + * Binds event listeners + * @private + */ +function bindEvents(): void { + // Context menu events + menu?.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + const action = target.closest('.context-menu-item')?.getAttribute('data-action'); + + if (action && currentMessage) { + handleAction(action, currentMessage); + } + }); + + // Close menu when clicking outside + document.addEventListener('click', (e) => { + if (!menu?.contains(e.target as Node)) { + hide(); + } + }); + + // Edit dialog events + const editCancelBtn = editDialog?.querySelector('#edit-cancel'); + const editSaveBtn = editDialog?.querySelector('#edit-save'); + + editCancelBtn?.addEventListener('click', () => hideEditDialog()); + editSaveBtn?.addEventListener('click', () => saveEdit()); + + // Reply dialog events + const replyCancelBtn = replyDialog?.querySelector('#reply-cancel'); + const replySendBtn = replyDialog?.querySelector('#reply-send'); + + replyCancelBtn?.addEventListener('click', () => hideReplyDialog()); + replySendBtn?.addEventListener('click', () => sendReply()); + + // Keyboard shortcuts + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + hide(); + hideEditDialog(); + hideReplyDialog(); + } + }); +} + +/** + * Shows the context menu at the specified position + * @param {Message} message - The message to show menu for + * @param {number} x - X coordinate + * @param {number} y - Y coordinate + */ +export function show(message: Message, x: number, y: number): void { + if (!menu) return; + + currentMessage = message; + + // Only show edit and delete for own messages + const editItem = menu.querySelector('[data-action="edit"]') as HTMLElement; + const deleteItem = menu.querySelector('[data-action="delete"]') as HTMLElement; + + if (message.username === currentUser?.username) { + editItem.style.display = 'flex'; + deleteItem.style.display = 'flex'; + } else { + editItem.style.display = 'none'; + deleteItem.style.display = 'none'; + } + + // Calculate menu position + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + // Default menu size estimates + const menuWidth = 150; + const menuHeight = 120; + + let adjustedX = x; + let adjustedY = y; + + // Adjust horizontal position if menu would go off-screen + if (x + menuWidth > viewportWidth) { + adjustedX = x - menuWidth; + } + + // Adjust vertical position if menu would go off-screen + if (y + menuHeight > viewportHeight) { + adjustedY = y - menuHeight; + } + + // Ensure menu doesn't go off the left or top edges + adjustedX = Math.max(0, adjustedX); + adjustedY = Math.max(0, adjustedY); + + menu.style.left = `${adjustedX}px`; + menu.style.top = `${adjustedY}px`; + menu.style.display = 'block'; +} + +/** + * Hides the context menu + */ +export function hide(): void { + if (menu) { + menu.style.display = 'none'; + } + currentMessage = null; +} + +/** + * Handles context menu actions + * @param {string} action - The action to perform + * @param {Message} message - The message to act on + * @private + */ +function handleAction(action: string, message: Message): void { + hide(); + + switch (action) { + case 'edit': + showEditDialog(message); + break; + case 'delete': + deleteMessage(message); + break; + case 'reply': + showReplyDialog(message); + break; + } +} + +/** + * Shows the edit dialog + * @param {Message} message - The message to edit + * @private + */ +function showEditDialog(message: Message): void { + if (!editDialog) return; + + const textField = editDialog.querySelector('#edit-message-input') as any; + if (textField) { + textField.value = message.content; + } + + currentMessage = message; + (editDialog as any).open = true; + + // Focus the text field + setTimeout(() => { + textField?.focus(); + }, 100); +} + +/** + * Hides the edit dialog + * @private + */ +function hideEditDialog(): void { + if (editDialog) { + (editDialog as any).open = false; + } +} + +/** + * Saves the edited message + * @private + */ +function saveEdit(): void { + if (!currentMessage || !editDialog) return; + + const textField = editDialog.querySelector('#edit-message-input') as any; + const newContent = textField?.value?.trim() || ''; + + if (!newContent) { + showError('Message cannot be empty'); + return; + } + + const payload: WebSocketMessage = { + type: "editMessage", + data: { + message_id: currentMessage.id, + content: newContent + }, + credentials: { + scheme: "Bearer", + credentials: authToken! + } + }; + + let callback: ((e: MessageEvent) => void) | null = null; + callback = (e) => { + websocket.removeEventListener("message", callback!); + const response: WebSocketMessage = JSON.parse(e.data); + + if (response.error) { + showError(response.error.detail); + } else { + showSuccess('Message edited successfully'); + hideEditDialog(); + } + }; + websocket.addEventListener("message", callback); + websocket.send(JSON.stringify(payload)); +} + +/** + * Shows the reply dialog + * @param {Message} message - The message to reply to + * @private + */ +function showReplyDialog(message: Message): void { + if (!replyDialog) return; + + const preview = replyDialog.querySelector('#reply-preview') as HTMLElement; + if (preview) { + preview.innerHTML = ` +
+ ${message.username}: ${message.content} +
+ `; + } + + currentMessage = message; + (replyDialog as any).open = true; + + // Focus the text field + setTimeout(() => { + const textField = replyDialog?.querySelector('#reply-message-input') as any; + textField?.focus(); + }, 100); +} + +/** + * Hides the reply dialog + * @private + */ +function hideReplyDialog(): void { + if (replyDialog) { + (replyDialog as any).open = false; + } +} + +/** + * Sends the reply message + * @private + */ +function sendReply(): void { + if (!currentMessage || !replyDialog) return; + + const textField = replyDialog.querySelector('#reply-message-input') as any; + const content = textField?.value?.trim() || ''; + + if (!content) { + showError('Reply cannot be empty'); + return; + } + + const payload: WebSocketMessage = { + type: "replyMessage", + data: { + content: content, + reply_to_id: currentMessage.id + }, + credentials: { + scheme: "Bearer", + credentials: authToken! + } + }; + + let callback: ((e: MessageEvent) => void) | null = null; + callback = (e) => { + websocket.removeEventListener("message", callback!); + const response: WebSocketMessage = JSON.parse(e.data); + + if (response.error) { + showError(response.error.detail); + } else { + showSuccess('Reply sent successfully'); + hideReplyDialog(); + if (textField) { + textField.value = ''; + } + } + }; + websocket.addEventListener("message", callback); + websocket.send(JSON.stringify(payload)); +} + +/** + * Deletes a message + * @param {Message} message - The message to delete + * @private + */ +function deleteMessage(message: Message): void { + if (!confirm('Are you sure you want to delete this message?')) { + return; + } + + const payload: WebSocketMessage = { + type: "deleteMessage", + data: { + message_id: message.id + }, + credentials: { + scheme: "Bearer", + credentials: authToken! + } + }; + + let callback: ((e: MessageEvent) => void) | null = null; + callback = (e) => { + websocket.removeEventListener("message", callback!); + const response: WebSocketMessage = JSON.parse(e.data); + + if (response.error) { + showError(response.error.detail); + } else { + showSuccess('Message deleted successfully'); + } + }; + websocket.addEventListener("message", callback); + websocket.send(JSON.stringify(payload)); +} + +init(); \ No newline at end of file diff --git a/frontend/src/profile.ts b/frontend/src/profile.ts new file mode 100644 index 0000000..2ff08f4 --- /dev/null +++ b/frontend/src/profile.ts @@ -0,0 +1,44 @@ +/** + * @fileoverview Profile module entry point and initialization + * @description Coordinates profile system initialization and form handling + * @author Cursor + * @version 1.0.0 + */ + +import type { Dialog } from "mdui/components/dialog"; +import { loadProfileData } from './profile/editor'; +import { loadProfilePicture, initializeProfileUpload } from "./profile/upload"; +import { initializeProfileEditor } from './profile/editor'; + +// Handle profile form submission +const form = document.getElementById("profile-form")!; +const dialog = document.getElementById("profile-dialog") as Dialog; + +form.addEventListener("submit", async (e) => { + e.preventDefault(); + + // TODO: Process form data if needed + // For now, just close the dialog + dialog.open = false; +}); + +/** + * Initializes profile functionality after user login + * @function initializeProfile + * @example + * // Called after successful authentication + * initializeProfile(); + */ +export function initializeProfile(): void { + // Initialize profile modules + initializeProfileUpload(); + initializeProfileEditor(); + + // Load profile data + Promise.all([ + loadProfilePicture(), + loadProfileData() + ]).catch(error => { + console.error('Error initializing profile:', error); + }); +} \ No newline at end of file diff --git a/frontend/src/profile/api.ts b/frontend/src/profile/api.ts new file mode 100644 index 0000000..d446bca --- /dev/null +++ b/frontend/src/profile/api.ts @@ -0,0 +1,132 @@ +/** + * @fileoverview Profile-related API calls + * @description Handles all profile-related HTTP requests to the backend + * @author Cursor + * @version 1.0.0 + */ + +import { getAuthHeaders } from '../auth'; +import type { ProfileData, UploadResponse } from './types'; + +/** + * Loads user profile data from the server + * @async + * @function loadProfile + * @returns {Promise} User profile data or null if failed + * @example + * const profile = await loadProfile(); + * if (profile) { + * console.log('User nickname:', profile.nickname); + * } + */ +export async function loadProfile(): Promise { + try { + const response = await fetch('/api/user/profile', { + headers: getAuthHeaders() + }); + + if (response.ok) { + return await response.json(); + } + + return null; + } catch (error) { + console.error('Error loading profile:', error); + return null; + } +} + +/** + * Uploads a profile picture to the server + * @async + * @function uploadProfilePicture + * @param {Blob} file - The image file to upload + * @returns {Promise} Upload response with URL or null if failed + * @example + * const fileInput = document.getElementById('file-input'); + * const file = fileInput.files[0]; + * const result = await uploadProfilePicture(file); + * if (result) { + * console.log('Uploaded to:', result.profile_picture_url); + * } + */ +export async function uploadProfilePicture(file: Blob): Promise { + try { + const formData = new FormData(); + formData.append('profile_picture', file, 'profile_picture.jpg'); + + const response = await fetch('/api/upload-profile-picture', { + method: 'POST', + body: formData, + headers: getAuthHeaders(false) + }); + + if (response.ok) { + return await response.json(); + } + return null; + } catch (error) { + console.error('Upload error:', error); + return null; + } +} + +/** + * Updates user profile information + * @async + * @function updateProfile + * @param {Partial} data - Profile data to update + * @returns {Promise} True if update was successful, false otherwise + * @example + * const success = await updateProfile({ + * nickname: 'New Name', + * description: 'Updated bio' + * }); + * if (success) { + * console.log('Profile updated successfully'); + * } + */ +export async function updateProfile(data: Partial): Promise { + try { + const response = await fetch('/api/user/profile', { + method: 'PUT', + headers: getAuthHeaders(), + body: JSON.stringify(data) + }); + + return response.ok; + } catch (error) { + console.error('Error updating profile:', error); + return false; + } +} + +/** + * Updates user bio + * @async + * @function updateBio + * @param {string} bio - New bio text + * @returns {Promise} True if update was successful, false otherwise + * @example + * const success = await updateBio('My new bio text'); + * if (success) { + * console.log('Bio updated successfully'); + * } + */ +export async function updateBio(bio: string): Promise { + try { + const response = await fetch('/api/user/bio', { + method: 'PUT', + headers: { + ...getAuthHeaders(), + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ bio }) + }); + + return response.ok; + } catch (error) { + console.error('Error updating bio:', error); + return false; + } +} diff --git a/frontend/src/profile/editor.ts b/frontend/src/profile/editor.ts new file mode 100644 index 0000000..d37aebf --- /dev/null +++ b/frontend/src/profile/editor.ts @@ -0,0 +1,151 @@ +/** + * @fileoverview Profile editing functionality + * @description Handles profile form editing and MDUI text field integration + * @author Cursor + * @version 1.0.0 + */ + +import { updateProfile } from './api'; +import { loadProfile } from './api'; +import { showSuccess, showError } from '../utils/notification'; +import type { TextField } from 'mdui/components/text-field'; + +let profileForm = document.getElementById('profile-form')!; +let nicknameField = document.getElementById('username-field') as unknown as TextField; +let descriptionField = document.getElementById('description-field') as unknown as TextField; + +/** + * Initialization state flag + * @type {boolean} + */ +let isInitialized = false; + +/** + * Sets the username field value + * @param {string} value - The username value to set + * @function setUsernameValue + * @example + * setUsernameValue('John Doe'); + */ +export function setUsernameValue(value: string): void { + if (nicknameField && nicknameField.value !== undefined) { + nicknameField.value = value; + } +} + +/** + * Sets the description field value + * @param {string} value - The description value to set + * @function setDescriptionValue + * @example + * setDescriptionValue('Software Developer'); + */ +export function setDescriptionValue(value: string): void { + if (descriptionField && descriptionField.value !== undefined) { + descriptionField.value = value; + } +} + +/** + * Gets the current username field value + * @returns {string} The current username value + * @function getUsernameValue + * @example + * const username = getUsernameValue(); + * console.log('Current username:', username); + */ +export function getUsernameValue(): string { + if (nicknameField && nicknameField.value !== undefined) { + return nicknameField.value; + } + return ''; +} + +/** + * Gets the current description field value + * @returns {string} The current description value + * @function getDescriptionValue + * @example + * const description = getDescriptionValue(); + * console.log('Current description:', description); + */ +export function getDescriptionValue(): string { + if (descriptionField && descriptionField.value !== undefined) { + return descriptionField.value; + } + return ''; +} + +/** + * Loads profile data from the server and populates the form fields + * @async + * @function loadProfileData + * @example + * await loadProfileData(); + */ +export async function loadProfileData(): Promise { + const userData = await loadProfile(); + if (userData) { + if (userData.nickname) { + setUsernameValue(userData.nickname); + } + if (userData.description) { + setDescriptionValue(userData.description); + } + } +} + +/** + * Handles profile form submission + * @async + * @function handleFormSubmission + * @param {Event} e - Form submission event + * @private + */ +async function handleFormSubmission(e: Event): Promise { + e.preventDefault(); + + const nickname = getUsernameValue(); + const description = getDescriptionValue(); + + if (nickname || description) { + const success = await updateProfile({ + nickname: nickname || undefined, + description: description || undefined + }); + + if (success) { + showSuccess('Профиль обновлен!'); + } else { + showError('Ошибка при обновлении профиля'); + } + } +} + +/** + * Sets up form submission handler + * @function setupFormHandler + * @private + */ +function setupFormHandler(): void { + if (isInitialized) return; + + // Get DOM elements + profileForm = document.getElementById('profile-form')!; + nicknameField = document.getElementById('username-field') as any; + descriptionField = document.getElementById('description-field') as any; + + profileForm.addEventListener('submit', handleFormSubmission); + + isInitialized = true; +} + +/** + * Initializes profile editor functionality + * @function initializeProfileEditor + * @example + * initializeProfileEditor(); + */ +export function initializeProfileEditor(): void { + setupFormHandler(); +} diff --git a/frontend/src/profile/image-cropper.ts b/frontend/src/profile/image-cropper.ts new file mode 100644 index 0000000..c4468d1 --- /dev/null +++ b/frontend/src/profile/image-cropper.ts @@ -0,0 +1,238 @@ +/** + * @fileoverview Canvas-based image cropping component + * @description Provides circular image cropping functionality with drag support + * @author Cursor + * @version 1.0.0 + */ + +import type { Size2D } from "../types"; + +/** + * Image cropper class for circular profile picture cropping + * @class ImageCropper + */ +export class ImageCropper { + private canvas: HTMLCanvasElement; + private ctx: CanvasRenderingContext2D; + + /** + * Image element to be cropped + * @type {HTMLImageElement} + * @private + */ + private image!: HTMLImageElement; + + /** + * Size of the crop area (diameter) + * @type {number} + * @private + */ + private cropSize: number = 200; + private isDragging: boolean = false; + + /** + * Starting position of the drag operation + * @type {Size2D} + * @private + */ + private dragStart: Size2D = { x: 0, y: 0 }; + + /** + * Current position of the crop area + * @type {Size2D} + * @private + */ + private cropPosition: Size2D = { x: 0, y: 0 }; + + /** + * Creates a new ImageCropper instance + * @param {HTMLElement} container - Container element to append the canvas to + * @constructor + * @example + * const cropper = new ImageCropper(document.getElementById('cropper-area')); + */ + constructor(container: HTMLElement) { + this.canvas = document.createElement('canvas'); + this.canvas.width = this.cropSize; + this.canvas.height = this.cropSize; + this.ctx = this.canvas.getContext('2d')!; + + container.appendChild(this.canvas); + this.setupEventListeners(); + } + + /** + * Sets up mouse and touch event listeners + * @function setupEventListeners + * @private + */ + private setupEventListeners(): void { + this.canvas.addEventListener('mousedown', this.onMouseDown.bind(this)); + this.canvas.addEventListener('mousemove', this.onMouseMove.bind(this)); + this.canvas.addEventListener('mouseup', this.onMouseUp.bind(this)); + this.canvas.addEventListener('touchstart', this.onTouchStart.bind(this)); + this.canvas.addEventListener('touchmove', this.onTouchMove.bind(this)); + this.canvas.addEventListener('touchend', this.onTouchEnd.bind(this)); + } + + /** + * Handles mouse down events + * @param {MouseEvent} e - Mouse event + * @function onMouseDown + * @private + */ + private onMouseDown(e: MouseEvent): void { + this.isDragging = true; + this.dragStart = { x: e.clientX, y: e.clientY }; + } + + /** + * Handles mouse move events during dragging + * @param {MouseEvent} e - Mouse event + * @function onMouseMove + * @private + */ + private onMouseMove(e: MouseEvent): void { + if (!this.isDragging) return; + + const deltaX = e.clientX - this.dragStart.x; + const deltaY = e.clientY - this.dragStart.y; + + this.cropPosition.x += deltaX; + this.cropPosition.y += deltaY; + + this.dragStart = { x: e.clientX, y: e.clientY }; + this.render(); + } + + /** + * Handles mouse up events + * @function onMouseUp + * @private + */ + private onMouseUp(): void { + this.isDragging = false; + } + + /** + * Handles touch start events + * @param {TouchEvent} e - Touch event + * @function onTouchStart + * @private + */ + private onTouchStart(e: TouchEvent): void { + e.preventDefault(); + const touch = e.touches[0]; + this.isDragging = true; + this.dragStart = { x: touch.clientX, y: touch.clientY }; + } + + /** + * Handles touch move events during dragging + * @param {TouchEvent} e - Touch event + * @function onTouchMove + * @private + */ + private onTouchMove(e: TouchEvent): void { + e.preventDefault(); + if (!this.isDragging) return; + + const touch = e.touches[0]; + const deltaX = touch.clientX - this.dragStart.x; + const deltaY = touch.clientY - this.dragStart.y; + + this.cropPosition.x += deltaX; + this.cropPosition.y += deltaY; + + this.dragStart = { x: touch.clientX, y: touch.clientY }; + this.render(); + } + + /** + * Handles touch end events + * @function onTouchEnd + * @private + */ + private onTouchEnd(): void { + this.isDragging = false; + } + + /** + * Loads an image file for cropping + * @param {File} file - Image file to load + * @returns {Promise} Promise that resolves when image is loaded + * @async + * @example + * await cropper.loadImage(fileInput.files[0]); + */ + loadImage(file: File): Promise { + return new Promise((resolve) => { + this.image = new Image(); + this.image.onload = () => { + this.render(); + resolve(); + }; + this.image.src = URL.createObjectURL(file); + }); + } + + /** + * Renders the image with circular crop overlay + * @function render + * @private + */ + private render(): void { + if (!this.image) return; + + // Clear canvas + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + + // Calculate crop area + const scale = Math.max(this.cropSize / this.image.width, this.cropSize / this.image.height); + const scaledWidth = this.image.width * scale; + const scaledHeight = this.image.height * scale; + + // Draw image + this.ctx.save(); + this.ctx.globalCompositeOperation = 'source-over'; + this.ctx.drawImage( + this.image, + this.cropPosition.x, + this.cropPosition.y, + scaledWidth, + scaledHeight + ); + this.ctx.restore(); + + // Draw crop overlay + this.ctx.save(); + this.ctx.globalCompositeOperation = 'destination-in'; + this.ctx.beginPath(); + this.ctx.arc(this.cropSize / 2, this.cropSize / 2, this.cropSize / 2, 0, 2 * Math.PI); + this.ctx.fill(); + this.ctx.restore(); + } + + /** + * Gets the cropped image as a data URL + * @returns {string} Data URL of the cropped image + * @example + * const croppedImage = cropper.getCroppedImage(); + * // Use croppedImage as src for an img element + */ + getCroppedImage(): string { + return this.canvas.toDataURL('image/jpeg', 0.8); + } + + /** + * Destroys the cropper and removes the canvas from DOM + * @function destroy + * @example + * cropper.destroy(); + */ + destroy(): void { + if (this.canvas.parentNode) { + this.canvas.parentNode.removeChild(this.canvas); + } + } +} diff --git a/frontend/src/profile/types.ts b/frontend/src/profile/types.ts new file mode 100644 index 0000000..ec134c6 --- /dev/null +++ b/frontend/src/profile/types.ts @@ -0,0 +1,28 @@ +/** + * @fileoverview Profile-specific type definitions + * @description Contains type definitions for profile-related functionality + * @author Cursor + * @version 1.0.0 + */ + +/** + * User profile data structure + * @interface ProfileData + * @property {string} [profile_picture] - URL to user's profile picture + * @property {string} [nickname] - User's display name + * @property {string} [description] - User's bio or description + */ +export interface ProfileData { + profile_picture?: string; + nickname?: string; + description?: string; +} + +/** + * Profile picture upload response structure + * @interface UploadResponse + * @property {string} profile_picture_url - URL to the uploaded profile picture + */ +export interface UploadResponse { + profile_picture_url: string; +} \ No newline at end of file diff --git a/frontend/src/profile/upload.ts b/frontend/src/profile/upload.ts new file mode 100644 index 0000000..0745eb7 --- /dev/null +++ b/frontend/src/profile/upload.ts @@ -0,0 +1,163 @@ +/** + * @fileoverview Profile picture upload functionality + * @description Handles file selection, image cropping, and profile picture upload + * @author Cursor + * @version 1.0.0 + */ + +import type { Dialog } from "mdui/components/dialog"; +import { ImageCropper } from './image-cropper'; +import { uploadProfilePicture } from './api'; +import { loadProfile } from './api'; +import { showSuccess, showError } from '../utils/notification'; + +/** + * Global image cropper instance + * @type {ImageCropper | null} + */ +let cropper: ImageCropper | null = null; + +/** + * Initialization state flag + * @type {boolean} + */ +let isInitialized = false; + +let cropperDialog = document.getElementById('cropper-dialog') as Dialog; +let fileInput = document.getElementById('pfp-file-input') as HTMLInputElement; +let uploadBtn = document.getElementById('upload-pfp-btn')!; +let cropSaveBtn = document.getElementById('crop-save')!; +let cropCancelBtn = document.getElementById('crop-cancel')!; +let cropperCloseBtn = document.getElementById('cropper-close')!; +let cropperArea = document.getElementById('cropper-area')!; + +/** + * Opens the image cropper with the selected file + * @async + * @function openCropper + * @param {File} file - The image file to crop + * @private + */ +async function openCropper(file: File): Promise { + // Clear previous cropper + cropperArea.innerHTML = ''; + + // Create new cropper + cropper = new ImageCropper(cropperArea); + + // Load image + await cropper.loadImage(file); + + // Open dialog + cropperDialog.open = true; +} + +/** + * Closes the image cropper and cleans up resources + * @function closeCropper + * @private + */ +function closeCropper(): void { + cropperDialog.open = false; + cropperArea.innerHTML = ''; + if (cropper) { + cropper.destroy(); + cropper = null; + } + fileInput.value = ''; +} + +/** + * Saves the cropped image and uploads it to the server + * @async + * @function saveCroppedImage + * @private + */ +async function saveCroppedImage(): Promise { + if (!cropper) return; + + const croppedImageData = cropper.getCroppedImage(); + + // Convert data URL to blob + const response = await fetch(croppedImageData); + const blob = await response.blob(); + + const result = await uploadProfilePicture(blob); + + if (result) { + // Update profile picture display + const profilePicture = document.getElementById('profile-picture') as HTMLImageElement; + profilePicture.src = result.profile_picture_url + '?t=' + Date.now(); // Cache bust + + // Close cropper + closeCropper(); + + // Show success message + showSuccess('Фото профиля обновлено!'); + } else { + showError('Ошибка при загрузке фото'); + } +} + +/** + * Sets up event listeners for upload functionality + * @function setupEventListeners + * @private + */ +function setupEventListeners(): void { + if (isInitialized) return; + + uploadBtn.addEventListener('click', () => { + fileInput.click(); + }); + + fileInput.addEventListener('change', (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (file) { + openCropper(file); + } + }); + + cropSaveBtn.addEventListener('click', () => { + saveCroppedImage(); + }); + + cropCancelBtn.addEventListener('click', () => { + closeCropper(); + }); + + cropperCloseBtn.addEventListener('click', () => { + closeCropper(); + }); + + isInitialized = true; +} + +/** + * Loads and displays the user's profile picture + * @async + * @function loadProfilePicture + * @example + * await loadProfilePicture(); + */ +export async function loadProfilePicture(): Promise { + const userData = await loadProfile(); + if (userData?.profile_picture) { + const url = `${userData.profile_picture}?t=${Date.now()}`; + + const profilePicture = document.getElementById('profile-picture') as HTMLImageElement; + const profilePicture2 = document.getElementById("preview1") as HTMLImageElement; + profilePicture.src = url; + profilePicture2.src = url; + } +} + +/** + * Initializes profile upload functionality + * @function initializeProfileUpload + * @example + * initializeProfileUpload(); + */ +export function initializeProfileUpload(): void { + setupEventListeners(); +} diff --git a/frontend/src/settings.ts b/frontend/src/settings.ts new file mode 100644 index 0000000..3c66910 --- /dev/null +++ b/frontend/src/settings.ts @@ -0,0 +1,104 @@ +/** + * @fileoverview Settings dialog management and panel navigation + * @description Handles settings dialog functionality and dynamic panel switching + * @author Cursor + * @version 1.0.0 + */ + +import type { Dialog } from "mdui/components/dialog"; + +const dialog = document.getElementById('settings-dialog') as Dialog; +const openButton = document.getElementById('settings-open')!; +const closeButton = document.getElementById('settings-close')!; + +// Settings panel management +const settingsList = document.querySelector('#settings-menu mdui-list')!; +const settingsPanels = document.querySelectorAll('.settings-panel'); + +/** + * Mapping between list item text and their corresponding panel IDs + * @type {Object.} + */ +const panelMapping = { + 'Уведомления': 'notifications-settings', + 'Внешний вид': 'appearance-settings', + 'Безопасность': 'security-settings', + 'Язык': 'language-settings', + 'Хранилище': 'storage-settings', + 'Помощь': 'help-settings', + 'О приложении': 'about-settings' +}; + +/** + * Handles click events on settings list items + * @param {Element} item - The clicked list item element + * @function handleListItemClick + * @private + */ +function handleListItemClick(item: Element): void { + // Remove active class from all items and panels + const listItems = settingsList.querySelectorAll('mdui-list-item'); + listItems.forEach(li => li.removeAttribute('active')); + settingsPanels.forEach(panel => panel.classList.remove('active')); + + // Add active class to clicked item + item.setAttribute('active', ''); + + // Show corresponding panel using the mapping + const itemText = item.textContent?.trim(); + const panelId = panelMapping[itemText as keyof typeof panelMapping]; + + if (panelId) { + const targetPanel = document.getElementById(panelId); + if (targetPanel) { + targetPanel.classList.add('active'); + } + } +} + +/** + * Sets up click listeners for all settings list items + * @function setupSettingsNavigation + * @private + */ +function setupSettingsNavigation(): void { + const listItems = settingsList.querySelectorAll('mdui-list-item'); + listItems.forEach((item) => { + item.addEventListener('click', () => handleListItemClick(item)); + }); +} + +/** + * Resets settings dialog to show the first panel + * @function resetToFirstPanel + * @private + */ +function resetToFirstPanel(): void { + const firstItem = settingsList.querySelector('mdui-list-item'); + const firstPanel = document.querySelector('.settings-panel'); + if (firstItem && firstPanel) { + settingsList.querySelectorAll('mdui-list-item').forEach(li => li.removeAttribute('active')); + settingsPanels.forEach(panel => panel.classList.remove('active')); + firstItem.setAttribute('active', ''); + firstPanel.classList.add('active'); + } +} + +/** + * Sets up dialog event listeners + * @function setupDialogListeners + * @private + */ +function setupDialogListeners(): void { + openButton.addEventListener('click', () => { + dialog.open = true; + resetToFirstPanel(); + }); + + closeButton.addEventListener('click', () => { + dialog.open = false; + }); +} + +setupSettingsNavigation(); +setupDialogListeners(); \ No newline at end of file diff --git a/frontend/src/types.ts b/frontend/src/types.ts index d341be8..a4b8077 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,42 +1,134 @@ +/** + * @fileoverview Global TypeScript type definitions + * @description Contains all type definitions used throughout the application + * @author Cursor + * @version 1.0.0 + */ + +/** + * HTTP headers object type + * @typedef {Object.} Headers + */ export type Headers = {[x: string]: string} +/** + * API error response structure + * @interface ErrorResponse + * @property {string} message - Error message from the server + */ export interface ErrorResponse { message: string; } +/** + * 2D coordinate structure + * @interface Size2D + * @property {number} x - X coordinate + * @property {number} y - Y coordinate + */ +export interface Size2D { + x: number; + y: number; +} // App types + +/** + * Chat message structure + * @interface Message + * @property {number} id - Unique message identifier + * @property {string} username - Username of the message sender + * @property {string} content - Message content + * @property {boolean} is_read - Whether the message has been read + * @property {boolean} is_edited - Whether the message has been edited + * @property {string} timestamp - ISO timestamp of the message + * @property {string} [profile_picture] - URL to sender's profile picture + * @property {Message} [reply_to] - The message this is replying to + */ export interface Message { id: number; username: string; content: string; is_read: boolean; + is_edited: boolean; timestamp: string; + profile_picture?: string; + reply_to?: Message; } +/** + * Collection of messages + * @interface Messages + * @property {Message[]} messages - Array of message objects + */ export interface Messages { messages: Message[]; } +/** + * User information structure + * @interface User + * @property {number} id - Unique user identifier + * @property {string} created_at - ISO timestamp of account creation + * @property {string} last_seen - ISO timestamp of last activity + * @property {boolean} online - Whether the user is currently online + * @property {string} username - Username + * @property {string} [bio] - User biography + */ export interface User { id: number; created_at: string; last_seen: string; online: boolean; username: string; + bio?: string; } +/** + * User profile response structure + * @interface UserProfile + * @property {number} id - Unique user identifier + * @property {string} username - Username + * @property {string} [profile_picture] - URL to user's profile picture + * @property {string} [bio] - User biography + * @property {boolean} online - Whether the user is currently online + * @property {string} last_seen - ISO timestamp of last activity + * @property {string} created_at - ISO timestamp of account creation + */ +export interface UserProfile { + id: number; + username: string; + profile_picture?: string; + bio?: string; + online: boolean; + last_seen: string; + created_at: string; +} // ---------- // API models // ---------- // Requests + +/** + * Login request structure + * @interface LoginRequest + * @property {string} username - Username for authentication + * @property {string} password - Password for authentication + */ export interface LoginRequest { username: string; password: string; } +/** + * Registration request structure + * @interface RegisterRequest + * @property {string} username - Desired username + * @property {string} password - Desired password + * @property {string} confirm_password - Password confirmation + */ export interface RegisterRequest { username: string; password: string; @@ -44,6 +136,13 @@ export interface RegisterRequest { } // Responses + +/** + * Login response structure + * @interface LoginResponse + * @property {User} user - User information + * @property {string} token - JWT authentication token + */ export interface LoginResponse { user: User; token: string; @@ -53,6 +152,14 @@ export interface LoginResponse { // WebSocket types // --------------- +/** + * WebSocket message structure + * @interface WebSocketMessage + * @property {string} type - Message type identifier + * @property {WebSocketCredentials} [credentials] - Authentication credentials + * @property {any} [data] - Message payload data + * @property {WebSocketError} [error] - Error information if applicable + */ export interface WebSocketMessage { type: string; credentials?: WebSocketCredentials; @@ -60,11 +167,23 @@ export interface WebSocketMessage { error?: WebSocketError; } +/** + * WebSocket error structure + * @interface WebSocketError + * @property {number} code - Error code + * @property {string} detail - Error detail message + */ export interface WebSocketError { code: number; detail: string; } +/** + * WebSocket authentication credentials + * @interface WebSocketCredentials + * @property {string} scheme - Authentication scheme (e.g., "Bearer") + * @property {string} credentials - Authentication token or credentials + */ export interface WebSocketCredentials { scheme: string; credentials: string; diff --git a/frontend/src/user-profile-dialog.ts b/frontend/src/user-profile-dialog.ts new file mode 100644 index 0000000..bce721c --- /dev/null +++ b/frontend/src/user-profile-dialog.ts @@ -0,0 +1,217 @@ +/** + * @fileoverview User profile dialog functionality + * @description Handles displaying user profiles in a modal dialog + * @author Cursor + * @version 1.0.0 + */ + +import { getAuthHeaders, currentUser } from "./auth"; +import { API_BASE_URL } from "./config"; +import type { UserProfile } from "./types"; +import { showError, showSuccess } from "./utils/notification"; +import { formatTime } from "./utils/utils"; + + +let dialog = document.getElementById("user-profile-dialog")!; +let currentProfile: UserProfile | null = null; +let isOwnProfile: boolean = false; + +function init() { + bindEvents(); +} + +/** + * Binds event listeners + * @private + */ +function bindEvents(): void { + // Edit bio events + const editBioBtn = dialog?.querySelector('#edit-bio-btn'); + const saveBioBtn = dialog?.querySelector('#save-bio-btn'); + const cancelBioBtn = dialog?.querySelector('#cancel-bio-btn'); + + editBioBtn?.addEventListener('click', () => startEditBio()); + saveBioBtn?.addEventListener('click', () => saveBio()); + cancelBioBtn?.addEventListener('click', () => cancelEditBio()); + + // Keyboard shortcuts + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && dialog?.getAttribute('open') !== null) { + hide(); + } + }); +} + +/** + * Shows the profile dialog for a specific user + * @param {string} username - Username to show profile for + */ +export async function show(username: string): Promise { + if (!dialog) return; + + try { + const response = await fetch(`${API_BASE_URL}/user/${username}`, { + headers: getAuthHeaders() + }); + + if (!response.ok) { + throw new Error('Failed to load user profile'); + } + + const profile: UserProfile = await response.json(); + currentProfile = profile; + isOwnProfile = profile.username === currentUser?.username; + populateDialog(profile); + (dialog as any).open = true; + + } catch (error) { + showError('Failed to load user profile'); + console.error('Error loading user profile:', error); + } +} + +/** + * Populates the dialog with user data + * @param {UserProfile} profile - User profile data + * @private + */ +function populateDialog(profile: UserProfile): void { + if (!dialog) return; + + // Profile picture + const profilePic = dialog.querySelector('.profile-picture') as HTMLImageElement; + profilePic.src = profile.profile_picture || './src/images/default-avatar.png'; + profilePic.addEventListener("error", () => { + profilePic.src = './src/images/default-avatar.png'; + }); + + // Username + const usernameEl = dialog.querySelector('.username') as HTMLElement; + usernameEl.textContent = profile.username; + + // Online status + const onlineStatus = dialog.querySelector('.online-status') as HTMLElement; + if (profile.online) { + onlineStatus.innerHTML = ' Online'; + onlineStatus.classList.add("online-status", "online"); + } else { + onlineStatus.innerHTML = ` Last seen ${formatTime(profile.last_seen)}`; + onlineStatus.classList.add("online-status", "offline"); + } + + // Bio + const bioDisplay = dialog.querySelector('.bio-display') as HTMLElement; + const bioEdit = dialog.querySelector('#bio-edit-field') as any; + + if (profile.bio) { + bioDisplay.textContent = profile.bio; + } else { + bioDisplay.textContent = isOwnProfile ? 'No bio yet. Click "Edit Bio" to add one!' : 'No bio available.'; + } + + if (bioEdit) { + bioEdit.value = profile.bio || ''; + } + + // Stats + const memberSince = dialog.querySelector('.member-since') as HTMLElement; + const lastSeen = dialog.querySelector('.last-seen') as HTMLElement; + + memberSince.textContent = formatTime(profile.created_at); + lastSeen.textContent = formatTime(profile.last_seen); +} + +/** + * Starts editing the bio + * @private + */ +function startEditBio(): void { + if (!dialog) return; + + const bioDisplay = dialog.querySelector('.bio-display') as HTMLElement; + const bioEdit = dialog.querySelector('#bio-edit-field') as any; + const bioActions = dialog.querySelector('.bio-actions') as HTMLElement; + const editBioBtn = dialog.querySelector('#edit-bio-btn') as HTMLElement; + + bioDisplay.style.display = 'none'; + if (bioEdit) bioEdit.style.display = 'block'; + bioActions.style.display = 'flex'; + editBioBtn.style.display = 'none'; + + if (bioEdit) { + bioEdit.focus(); + bioEdit.setSelectionRange(bioEdit.value.length, bioEdit.value.length); + } +} + +/** + * Saves the bio + * @private + */ +export async function saveBio(): Promise { + if (!dialog || !currentProfile) return; + + const bioEdit = dialog.querySelector('#bio-edit-field') as any; + const newBio = bioEdit?.value?.trim() || ''; + + try { + const response = await fetch(`${API_BASE_URL}/user/bio`, { + method: 'PUT', + headers: { + ...getAuthHeaders(), + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ bio: newBio }) + }); + + if (!response.ok) { + throw new Error('Failed to update bio'); + } + + const result = await response.json(); + currentProfile.bio = result.bio; + populateDialog(currentProfile); + cancelEditBio(); + showSuccess('Bio updated successfully'); + + } catch (error) { + showError('Failed to update bio'); + console.error('Error updating bio:', error); + } +} + +/** + * Cancels bio editing + * @private + */ +function cancelEditBio(): void { + if (!dialog) return; + + const bioDisplay = dialog.querySelector('.bio-display') as HTMLElement; + const bioEdit = dialog.querySelector('#bio-edit-field') as any; + const bioActions = dialog.querySelector('.bio-actions') as HTMLElement; + const editBioBtn = dialog.querySelector('#edit-bio-btn') as HTMLElement; + + bioDisplay.style.display = 'block'; + if (bioEdit) bioEdit.style.display = 'none'; + bioActions.style.display = 'none'; + editBioBtn.style.display = isOwnProfile ? 'block' : 'none'; + + // Reset bio edit to current value + if (bioEdit) { + bioEdit.value = currentProfile?.bio || ''; + } +} + +/** + * Hides the dialog + */ +export function hide(): void { + if (dialog) { + (dialog as any).open = false; + } + currentProfile = null; + isOwnProfile = false; +} + +init(); \ No newline at end of file diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts deleted file mode 100644 index 84499f3..0000000 --- a/frontend/src/utils.ts +++ /dev/null @@ -1,12 +0,0 @@ -export function formatTime(dateString: string) { - const date = new Date(dateString); - let hours = date.getHours(); - let minutes = date.getMinutes(); - const hoursString = hours < 10 ? '0' + hours : hours; - const minutesString = minutes < 10 ? '0' + minutes : minutes; - return hoursString + ':' + minutesString; -} - -export function delay(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); -} \ No newline at end of file diff --git a/frontend/src/material.ts b/frontend/src/utils/material.ts similarity index 61% rename from frontend/src/material.ts rename to frontend/src/utils/material.ts index 59d9a91..265d42b 100644 --- a/frontend/src/material.ts +++ b/frontend/src/utils/material.ts @@ -1,3 +1,10 @@ +/** + * @fileoverview MDUI component imports and configuration + * @description Imports all required MDUI components and sets up the theme + * @author Cursor + * @version 1.0.0 + */ + import 'mdui/components/tabs'; import 'mdui/components/tab'; import 'mdui/components/tab-panel'; @@ -9,6 +16,9 @@ import 'mdui/components/fab'; import 'mdui/components/dialog'; import 'mdui/components/button'; import 'mdui/components/text-field'; +import 'mdui/components/button-icon'; +import 'mdui/components/top-app-bar'; +import 'mdui/components/top-app-bar-title'; import { setColorScheme } from 'mdui/functions/setColorScheme.js'; diff --git a/frontend/src/utils/notification.ts b/frontend/src/utils/notification.ts new file mode 100644 index 0000000..b655dc1 --- /dev/null +++ b/frontend/src/utils/notification.ts @@ -0,0 +1,69 @@ +/** + * @fileoverview User notification system + * @description Provides toast-style notifications for user feedback + * @author Cursor + * @version 1.0.0 + */ + +/** + * Notification type enumeration + * @typedef {'success' | 'error'} NotificationType + */ +export type NotificationType = 'success' | 'error'; + +/** + * Shows a notification with the specified message and type + * @param {string} message - The message to display + * @param {NotificationType} type - The type of notification (success or error) + * @function showNotification + * @private + */ +function showNotification(message: string, type: NotificationType): void { + const notification = document.createElement('div'); + notification.textContent = message; + notification.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + padding: 12px 16px; + border-radius: 4px; + color: white; + background: ${type === 'success' ? '#4caf50' : '#f44336'}; + z-index: 10000; + font-family: inherit; + box-shadow: 0 2px 8px rgba(0,0,0,0.2); + transition: opacity 0.3s ease; + `; + + document.body.appendChild(notification); + + // Fade out and remove + setTimeout(() => { + notification.style.opacity = '0'; + setTimeout(() => { + notification.remove(); + }, 300); + }, 3000); +} + +/** + * Shows a success notification + * @param {string} message - The success message to display + * @function showSuccess + * @example + * showSuccess('Profile updated successfully!'); + */ +export function showSuccess(message: string): void { + showNotification(message, 'success'); +} + +/** + * Shows an error notification + * @param {string} message - The error message to display + * @function showError + * @example + * showError('Failed to update profile'); + */ +export function showError(message: string): void { + showNotification(message, 'error'); +} diff --git a/frontend/src/utils/utils.ts b/frontend/src/utils/utils.ts new file mode 100644 index 0000000..2220c33 --- /dev/null +++ b/frontend/src/utils/utils.ts @@ -0,0 +1,33 @@ +/** + * @fileoverview Utility functions used throughout the application + * @description Contains helper functions for common operations + * @author Cursor + * @version 1.0.0 + */ + +/** + * Formats a timestamp string to HH:MM format + * @param {string} dateString - ISO timestamp string to format + * @returns {string} Formatted time string in HH:MM format + * @example + * formatTime('2024-01-15T14:30:00Z'); // Returns "14:30" + */ +export function formatTime(dateString: string): string { + const date = new Date(dateString); + let hours = date.getHours(); + let minutes = date.getMinutes(); + const hoursString = hours < 10 ? '0' + hours : hours; + const minutesString = minutes < 10 ? '0' + minutes : minutes; + return hoursString + ':' + minutesString; +} + +/** + * Creates a promise that resolves after a specified delay + * @param {number} ms - Delay time in milliseconds + * @returns {Promise} Promise that resolves after the delay + * @example + * await delay(1000); // Wait for 1 second + */ +export function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} \ No newline at end of file diff --git a/frontend/src/websocket.ts b/frontend/src/websocket.ts index 6cafd7e..b514ca0 100644 --- a/frontend/src/websocket.ts +++ b/frontend/src/websocket.ts @@ -1,14 +1,30 @@ -import { currentUser } from "./auth"; -import { addMessage } from "./chat"; -import { API_FULL_BASE_URL } from "./config"; -import type { WebSocketMessage, Message } from "./types"; -import { delay } from "./utils"; +/** + * @fileoverview WebSocket connection management for real-time chat + * @description Handles WebSocket connections, message processing, and auto-reconnection + * @author Cursor + * @version 1.0.0 + */ -function create() { +import { handleWebSocketMessage } from "./chat"; +import { API_FULL_BASE_URL } from "./config"; +import type { WebSocketMessage } from "./types"; +import { delay } from "./utils/utils"; + +/** + * Creates a new WebSocket connection to the chat server + * @function create + * @returns {WebSocket} New WebSocket instance + * @private + */ +function create(): WebSocket { return new WebSocket(`ws://${API_FULL_BASE_URL}/chat/ws`); } -export let websocket = create(); +/** + * Global WebSocket instance + * @type {WebSocket} + */ +export let websocket: WebSocket = create(); // -------------- // Initialization @@ -16,13 +32,7 @@ export let websocket = create(); websocket.addEventListener("message", (e) => { const message: WebSocketMessage = JSON.parse(e.data); - switch (message.type) { - case "newMessage": { - const newMessage: Message = message.data; - addMessage(newMessage, newMessage.username == currentUser!.username); - break; - } - } + handleWebSocketMessage(message); }); websocket.addEventListener("error", async () => {