Add profile system, add settings, implement message replying, deleting and editing

This commit is contained in:
2025-08-23 16:09:31 +03:00
Unverified
40 changed files with 3142 additions and 166 deletions
+11
View File
@@ -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.
+8
View File
@@ -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.
+1 -1
View File
@@ -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
+3 -2
View File
@@ -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)
app.include_router(messaging.router)
app.include_router(profile.router)
+2 -2
View File
@@ -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
+4
View File
@@ -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)
+1 -1
View File
@@ -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:
+37 -1
View File
@@ -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
+3 -1
View File
@@ -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
websockets>=15.0.1
Pillow>=10.0.0
python-multipart>=0.0.6
+148 -25
View File
@@ -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"]
+144
View File
@@ -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
)
+223 -18
View File
@@ -21,7 +21,7 @@
<div id="login-alerts"></div>
<form id="login-form-element">
<mdui-text-field
<mdui-text-field
label="Имя пользователя"
id="login-username"
name="username"
@@ -30,7 +30,7 @@
autocomplete="username"
required>
</mdui-text-field>
<mdui-text-field
<mdui-text-field
label="Пароль"
id="login-password"
name="password"
@@ -120,7 +120,9 @@
<header class="chat-header-left">
<div id="productname">Loading...</div>
<div class="profile">
<a href="#" id="profbut"><img src="./src/images/default-avatar.png" alt=""></a>
<a href="#" id="profile-open">
<img src="./src/images/default-avatar.png" alt="" id="preview1" />
</a>
</div>
</header>
<div class="chat-tabs">
@@ -137,10 +139,10 @@
<mdui-tab-panel slot="panel" value="chats">
<mdui-list>
<mdui-list-item headline="Общий чат" description="Вы: Последнее сообщение" id="chat1but">
<mdui-list-item headline="Общий чат" description="Вы: Последнее сообщение" id="chat-list-chat-1">
<img src="./src/images/default-avatar.png" alt="" slot="icon" />
</mdui-list-item>
<mdui-list-item headline="Общий чат 2" description="Вы: Последнее сообщение" id="chat1but2">
<mdui-list-item headline="Общий чат 2" description="Вы: Последнее сообщение" id="chat-list-chat-2">
<img src="./src/images/default-avatar.png" alt="" slot="icon" />
</mdui-list-item>
</mdui-list>
@@ -150,24 +152,25 @@
</mdui-tabs>
</div>
<mdui-bottom-app-bar>
<mdui-button-icon icon="settings--filled"></mdui-button-icon>
<mdui-button-icon icon="settings--filled" id="settings-open"></mdui-button-icon>
<mdui-button-icon icon="group_add--filled"></mdui-button-icon>
<div style="flex-grow: 1"></div>
<mdui-fab icon="edit--filled"></mdui-fab>
</mdui-bottom-app-bar>
</div>
<div class="chat-container">
<div class="chat-main" id="conteinerchat">
<div class="chat-main" id="chat-inner">
<div class="chat-header">
<img src="src/images/default-avatar.png" alt="Avatar" class="chat-header-avatar">
<div class="chat-header-info">
<div class="info-chat">
<h4 id="namechat">Общий чат</h4>
<h4 id="chat-name">Общий чат</h4>
<p>
<span class="online-status"></span> Онлайн
<span class="online-status"></span>
Онлайн
</p>
</div>
<a href="#" id="chat-recrol">Свернуть чат</a>
<a href="#" id="hide-chat">Свернуть чат</a>
</div>
</div>
@@ -189,15 +192,217 @@
</div>
</div>
</div>
<mdui-dialog id="profile-dialog" close-on-overlay-click close-on-esc>
<p>
Скоро будет...
</p>
<p>
<mdui-button id="profile-dialog-close">Закрыть</mdui-button>
</p>
<div class="content">
<div class="header-top">
<div class="profile-picture-container">
<img id="profile-picture" src="./src/images/default-avatar.png" alt="Ваше фото" />
<mdui-button-icon icon="camera_alt--filled" id="upload-pfp-btn" class="upload-overlay" variant="filled"></mdui-button-icon>
<input type="file" id="pfp-file-input" accept="image/*" style="display: none;">
</div>
<mdui-text-field id="username-field" label="Имя пользователя" variant="outlined" value="user123" autocomplete="username"></mdui-text-field>
</div>
<form id="profile-form">
<mdui-text-field
id="description-field"
label="О себе"
variant="outlined"
multiline
rows="3"
placeholder="Расскажите о себе..."
autocomplete="none"></mdui-text-field>
<div class="dialog-actions">
<mdui-button type="submit" id="profile-submit">Сохранить изменения</mdui-button>
<mdui-button id="profile-dialog-close" variant="outlined">Закрыть</mdui-button>
</div>
</div>
</div>
</mdui-dialog>
<!-- Profile Picture Cropper Dialog -->
<mdui-dialog id="cropper-dialog" close-on-overlay-click close-on-esc>
<div class="cropper-dialog-content">
<div class="cropper-header">
<h3>Обрезать фото профиля</h3>
<mdui-button-icon icon="close" id="cropper-close"></mdui-button-icon>
</div>
<div class="cropper-container">
<div id="cropper-area"></div>
</div>
<div class="cropper-actions">
<mdui-button id="crop-cancel" variant="outlined">Отмена</mdui-button>
<mdui-button id="crop-save">Сохранить</mdui-button>
</div>
</div>
</mdui-dialog>
<mdui-dialog id="settings-dialog" close-on-overlay-click close-on-esc fullscreen>
<div class="fullscreen-wrapper">
<div id="settings-dialog-inner">
<div class="header">
<mdui-button-icon icon="close" id="settings-close"></mdui-button-icon>
<mdui-top-app-bar-title>Настройки</mdui-top-app-bar-title>
</div>
<div id="settings-menu">
<mdui-list>
<mdui-list-item icon="notifications--filled" rounded active>Уведомления</mdui-list-item>
<mdui-list-item icon="palette--filled" rounded>Внешний вид</mdui-list-item>
<mdui-list-item icon="security--filled" rounded>Безопасность</mdui-list-item>
<mdui-list-item icon="language--filled" rounded>Язык</mdui-list-item>
<mdui-list-item icon="storage--filled" rounded>Хранилище</mdui-list-item>
<mdui-list-item icon="help--filled" rounded>Помощь</mdui-list-item>
<mdui-list-item icon="info--filled" rounded>О приложении</mdui-list-item>
</mdui-list>
<div class="screen">
<div id="notifications-settings" class="settings-panel active">
<h3>Уведомления</h3>
<mdui-switch checked>Новые сообщения</mdui-switch>
<mdui-switch checked>Звуковые уведомления</mdui-switch>
<mdui-switch>Уведомления о статусе</mdui-switch>
<mdui-switch checked>Email уведомления</mdui-switch>
</div>
<div id="appearance-settings" class="settings-panel">
<h3>Внешний вид</h3>
<mdui-select label="Тема" variant="outlined">
<mdui-menu-item value="dark">Тёмная</mdui-menu-item>
<mdui-menu-item value="light">Светлая</mdui-menu-item>
<mdui-menu-item value="auto">Авто</mdui-menu-item>
</mdui-select>
<mdui-select label="Размер шрифта" variant="outlined">
<mdui-menu-item value="small">Маленький</mdui-menu-item>
<mdui-menu-item value="medium">Средний</mdui-menu-item>
<mdui-menu-item value="large">Большой</mdui-menu-item>
</mdui-select>
</div>
<div id="security-settings" class="settings-panel">
<h3>Безопасность</h3>
<mdui-button variant="outlined">Изменить пароль</mdui-button>
<mdui-button variant="outlined">Двухфакторная аутентификация</mdui-button>
<mdui-switch>Автоматический выход</mdui-switch>
</div>
<div id="language-settings" class="settings-panel">
<h3>Язык</h3>
<mdui-select label="Выберите язык" variant="outlined">
<mdui-menu-item value="ru">Русский</mdui-menu-item>
<mdui-menu-item value="en">English</mdui-menu-item>
<mdui-menu-item value="es">Español</mdui-menu-item>
</mdui-select>
</div>
<div id="storage-settings" class="settings-panel">
<h3>Хранилище</h3>
<p>Использовано: 2.5 ГБ из 10 ГБ</p>
<mdui-linear-progress value="25"></mdui-linear-progress>
<mdui-button variant="outlined">Очистить кэш</mdui-button>
</div>
<div id="help-settings" class="settings-panel">
<h3>Помощь</h3>
<mdui-button variant="outlined">Руководство пользователя</mdui-button>
<mdui-button variant="outlined">Связаться с поддержкой</mdui-button>
<mdui-button variant="outlined">FAQ</mdui-button>
</div>
<div id="about-settings" class="settings-panel">
<h3>О приложении</h3>
<p>Версия: 1.0.0</p>
<p>© 2024 From Chat. Все права защищены.</p>
<mdui-button variant="outlined">Политика конфиденциальности</mdui-button>
<mdui-button variant="outlined">Условия использования</mdui-button>
</div>
</div>
</div>
</div>
</div>
</mdui-dialog>
<div id="message-context-menu">
<div class="context-menu-item" data-action="reply">
<span class="material-symbols">reply</span>
Reply
</div>
<div class="context-menu-item" data-action="edit">
<span class="material-symbols">edit</span>
Edit
</div>
<div class="context-menu-item" data-action="delete">
<span class="material-symbols">delete</span>
Delete
</div>
</div>
<mdui-dialog id="edit-message-dialog" close-on-overlay-click close-on-esc>
<div class="dialog-content">
<h3>Edit Message</h3>
<mdui-text-field
id="edit-message-input"
label="Edit Message"
variant="outlined"
multiline
rows="4"
placeholder="Edit your message..."
maxlength="1000">
</mdui-text-field>
<div class="dialog-actions">
<mdui-button id="edit-cancel" variant="outlined">Cancel</mdui-button>
<mdui-button id="edit-save">Save</mdui-button>
</div>
</div>
</mdui-dialog>
<mdui-dialog id="reply-message-dialog" close-on-overlay-click close-on-esc>
<div class="dialog-content">
<h3>Reply to Message</h3>
<div class="reply-preview" id="reply-preview"></div>
<mdui-text-field
id="reply-message-input"
label="Reply"
variant="outlined"
multiline
rows="4"
placeholder="Type your reply..."
maxlength="1000">
</mdui-text-field>
<div class="dialog-actions">
<mdui-button id="reply-cancel" variant="outlined">Cancel</mdui-button>
<mdui-button id="reply-send">Send Reply</mdui-button>
</div>
</div>
</mdui-dialog>
<mdui-dialog id="user-profile-dialog" close-on-overlay-click close-on-esc>
<div class="content">
<div class="profile-picture-section">
<img class="profile-picture" src="" alt="Profile Picture">
</div>
<div class="profile-info">
<div class="username-section">
<h4 class="username"></h4>
<div class="online-status"></div>
</div>
<div class="bio-section">
<label>Bio:</label>
<div class="bio-display"></div>
</div>
<div class="profile-stats">
<div class="stat">
<span class="stat-label">Member since:</span>
<span class="stat-value member-since"></span>
</div>
<div class="stat">
<span class="stat-label">Last seen:</span>
<span class="stat-value last-seen"></span>
</div>
</div>
</div>
</div>
</mdui-dialog>
<script src="src/main.ts" type="module"></script>
</body>
</html>
</html>
+135 -28
View File
@@ -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<void> {
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<void> {
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<void> {
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<void> {
// 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();
+152 -7
View File
@@ -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 = `
<div class="reply-content">
<span class="reply-username">${message.reply_to.username}</span>
<span class="reply-text">${message.reply_to.content}</span>
</div>
`;
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();
});
});
/**
* 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;
}
}
+24
View File
@@ -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";
+142 -5
View File
@@ -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 @@
}
}
}
}
}
// 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;
}
}
}
-1
View File
@@ -89,7 +89,6 @@
img {
$size: 45px;
display: flex;
border-radius: 50%;
width: $size;
+239
View File
@@ -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;
}
}
+99
View File
@@ -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;
}
}
}
}
}
}
}
-4
View File
@@ -4,10 +4,6 @@
text-align: center;
}
.mt-3 {
margin-top: 1rem;
}
.alert {
padding: 0.8rem 1rem;
border-radius: 6px;
+3
View File
@@ -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;
}
+7 -1
View File
@@ -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;
+63 -29
View File
@@ -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;
});
/**
* 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();
-11
View File
@@ -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);
+13 -3
View File
@@ -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";
import "./init";
import "./profile";
import "./message-context-menu";
import "./user-profile-dialog";
+348
View File
@@ -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 = `
<div class="reply-preview-content">
<strong>${message.username}</strong>: ${message.content}
</div>
`;
}
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();
+44
View File
@@ -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);
});
}
+132
View File
@@ -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<ProfileData | null>} 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<ProfileData | null> {
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<UploadResponse | null>} 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<UploadResponse | null> {
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<ProfileData>} data - Profile data to update
* @returns {Promise<boolean>} 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<ProfileData>): Promise<boolean> {
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<boolean>} 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<boolean> {
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;
}
}
+151
View File
@@ -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<void> {
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<void> {
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();
}
+238
View File
@@ -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<void>} Promise that resolves when image is loaded
* @async
* @example
* await cropper.loadImage(fileInput.files[0]);
*/
loadImage(file: File): Promise<void> {
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);
}
}
}
+28
View File
@@ -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;
}
+163
View File
@@ -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<void> {
// 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<void> {
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<void> {
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();
}
+104
View File
@@ -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.<string, string>}
*/
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();
+119
View File
@@ -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.<string, string>} 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;
+217
View File
@@ -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<void> {
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 = '<span class="online-indicator"></span> Online';
onlineStatus.classList.add("online-status", "online");
} else {
onlineStatus.innerHTML = `<span class="offline-indicator"></span> 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<void> {
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();
-12
View File
@@ -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<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
@@ -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';
+69
View File
@@ -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');
}
+33
View File
@@ -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<void>} Promise that resolves after the delay
* @example
* await delay(1000); // Wait for 1 second
*/
export function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
+24 -14
View File
@@ -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 () => {