mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Add profile system, add settings, implement message replying, deleting and editing
This commit is contained in:
@@ -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.
|
||||||
@@ -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
@@ -376,7 +376,7 @@ pyrightconfig.json
|
|||||||
|
|
||||||
# Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option)
|
# Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option)
|
||||||
|
|
||||||
instance
|
data
|
||||||
.vite
|
.vite
|
||||||
*.db
|
*.db
|
||||||
package-lock.json
|
package-lock.json
|
||||||
+2
-1
@@ -1,7 +1,7 @@
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from routes import account, messaging
|
from routes import account, messaging, profile
|
||||||
|
|
||||||
# Инициализация FastAPI
|
# Инициализация FastAPI
|
||||||
app = FastAPI(title="PixelChat")
|
app = FastAPI(title="PixelChat")
|
||||||
@@ -18,3 +18,4 @@ app.add_middleware(
|
|||||||
# Routes
|
# Routes
|
||||||
app.include_router(account.router)
|
app.include_router(account.router)
|
||||||
app.include_router(messaging.router)
|
app.include_router(messaging.router)
|
||||||
|
app.include_router(profile.router)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
DATABASE_URL = "sqlite:///./pixelchat.db"
|
DATABASE_URL = "sqlite:///./data/database.db"
|
||||||
JWT_SECRET_KEY = "pixelchat-jwt-secret"
|
JWT_SECRET_KEY = "fromchat-jwt-secret"
|
||||||
JWT_ALGORITHM = "HS256"
|
JWT_ALGORITHM = "HS256"
|
||||||
ACCESS_TOKEN_EXPIRE_HOURS = 24
|
ACCESS_TOKEN_EXPIRE_HOURS = 24
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
|
import os
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from constants import DATABASE_URL
|
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})
|
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
@@ -19,7 +19,7 @@ def get_db():
|
|||||||
def get_current_user(
|
def get_current_user(
|
||||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
) -> User:
|
||||||
token = credentials.credentials
|
token = credentials.credentials
|
||||||
payload = verify_token(token)
|
payload = verify_token(token)
|
||||||
if not payload:
|
if not payload:
|
||||||
|
|||||||
+37
-1
@@ -1,5 +1,5 @@
|
|||||||
from sqlalchemy.ext.declarative import declarative_base
|
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 sqlalchemy.orm import relationship
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from db import engine
|
from db import engine
|
||||||
@@ -15,6 +15,8 @@ class User(Base):
|
|||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
username = Column(String(50), unique=True, nullable=False, index=True)
|
username = Column(String(50), unique=True, nullable=False, index=True)
|
||||||
password_hash = Column(String(200), nullable=False)
|
password_hash = Column(String(200), nullable=False)
|
||||||
|
profile_picture = Column(String(255), nullable=True)
|
||||||
|
bio = Column(Text, nullable=True)
|
||||||
online = Column(Boolean, default=False)
|
online = Column(Boolean, default=False)
|
||||||
last_seen = Column(DateTime, default=datetime.now)
|
last_seen = Column(DateTime, default=datetime.now)
|
||||||
created_at = 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)
|
timestamp = Column(DateTime, default=datetime.now)
|
||||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
||||||
is_read = Column(Boolean, default=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")
|
author = relationship("User", back_populates="messages")
|
||||||
|
reply_to = relationship("Message", remote_side=[id])
|
||||||
|
|
||||||
|
|
||||||
# Pydantic модели
|
# Pydantic модели
|
||||||
@@ -49,6 +54,36 @@ class SendMessageRequest(BaseModel):
|
|||||||
content: str
|
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):
|
class MessageResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
content: str
|
content: str
|
||||||
@@ -56,6 +91,7 @@ class MessageResponse(BaseModel):
|
|||||||
is_author: bool
|
is_author: bool
|
||||||
is_read: bool
|
is_read: bool
|
||||||
username: str
|
username: str
|
||||||
|
profile_picture: str | None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|||||||
@@ -4,3 +4,5 @@ pydantic>=2.11.7
|
|||||||
sqlalchemy>=2.0.43
|
sqlalchemy>=2.0.43
|
||||||
bcrypt>=4.3.0
|
bcrypt>=4.3.0
|
||||||
websockets>=15.0.1
|
websockets>=15.0.1
|
||||||
|
Pillow>=10.0.0
|
||||||
|
python-multipart>=0.0.6
|
||||||
+148
-25
@@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisco
|
|||||||
from fastapi.security import HTTPAuthorizationCredentials
|
from fastapi.security import HTTPAuthorizationCredentials
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from dependencies import get_current_user, get_db
|
from dependencies import get_current_user, get_db
|
||||||
from models import Message, SendMessageRequest, User
|
from models import Message, SendMessageRequest, EditMessageRequest, ReplyMessageRequest, User
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = logging.getLogger("uvicorn.error")
|
logger = logging.getLogger("uvicorn.error")
|
||||||
@@ -15,22 +15,19 @@ def convert_message(msg: Message) -> dict:
|
|||||||
"content": msg.content,
|
"content": msg.content,
|
||||||
"timestamp": msg.timestamp.isoformat(),
|
"timestamp": msg.timestamp.isoformat(),
|
||||||
"is_read": msg.is_read,
|
"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 = []
|
@router.post("/send_message")
|
||||||
for msg in messages:
|
async def send_message(
|
||||||
messages_data.append(convert_message(msg))
|
request: SendMessageRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
return {
|
db: Session = Depends(get_db)
|
||||||
"status": "success",
|
):
|
||||||
"messages": messages_data
|
|
||||||
}
|
|
||||||
|
|
||||||
async def send_message_inner(request: SendMessageRequest, current_user: User, db: Session):
|
|
||||||
if not request.content.strip():
|
if not request.content.strip():
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
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)}
|
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")
|
@router.get("/get_messages")
|
||||||
async def get_messages(db: Session = Depends(get_db)):
|
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:
|
class MessaggingSocketManager:
|
||||||
@@ -95,7 +168,7 @@ class MessaggingSocketManager:
|
|||||||
if not current_user:
|
if not current_user:
|
||||||
raise HTTPException(401)
|
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:
|
except HTTPException as e:
|
||||||
await self.send_error(websocket, type, e)
|
await self.send_error(websocket, type, e)
|
||||||
elif type == "sendMessage":
|
elif type == "sendMessage":
|
||||||
@@ -106,7 +179,57 @@ class MessaggingSocketManager:
|
|||||||
|
|
||||||
request: SendMessageRequest = SendMessageRequest.model_validate(data["data"])
|
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({
|
await self.broadcast({
|
||||||
"type": "newMessage",
|
"type": "newMessage",
|
||||||
"data": response["message"]
|
"data": response["message"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
+220
-15
@@ -120,7 +120,9 @@
|
|||||||
<header class="chat-header-left">
|
<header class="chat-header-left">
|
||||||
<div id="productname">Loading...</div>
|
<div id="productname">Loading...</div>
|
||||||
<div class="profile">
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="chat-tabs">
|
<div class="chat-tabs">
|
||||||
@@ -137,10 +139,10 @@
|
|||||||
|
|
||||||
<mdui-tab-panel slot="panel" value="chats">
|
<mdui-tab-panel slot="panel" value="chats">
|
||||||
<mdui-list>
|
<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" />
|
<img src="./src/images/default-avatar.png" alt="" slot="icon" />
|
||||||
</mdui-list-item>
|
</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" />
|
<img src="./src/images/default-avatar.png" alt="" slot="icon" />
|
||||||
</mdui-list-item>
|
</mdui-list-item>
|
||||||
</mdui-list>
|
</mdui-list>
|
||||||
@@ -150,24 +152,25 @@
|
|||||||
</mdui-tabs>
|
</mdui-tabs>
|
||||||
</div>
|
</div>
|
||||||
<mdui-bottom-app-bar>
|
<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>
|
<mdui-button-icon icon="group_add--filled"></mdui-button-icon>
|
||||||
<div style="flex-grow: 1"></div>
|
<div style="flex-grow: 1"></div>
|
||||||
<mdui-fab icon="edit--filled"></mdui-fab>
|
<mdui-fab icon="edit--filled"></mdui-fab>
|
||||||
</mdui-bottom-app-bar>
|
</mdui-bottom-app-bar>
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-container">
|
<div class="chat-container">
|
||||||
<div class="chat-main" id="conteinerchat">
|
<div class="chat-main" id="chat-inner">
|
||||||
<div class="chat-header">
|
<div class="chat-header">
|
||||||
<img src="src/images/default-avatar.png" alt="Avatar" class="chat-header-avatar">
|
<img src="src/images/default-avatar.png" alt="Avatar" class="chat-header-avatar">
|
||||||
<div class="chat-header-info">
|
<div class="chat-header-info">
|
||||||
<div class="info-chat">
|
<div class="info-chat">
|
||||||
<h4 id="namechat">Общий чат</h4>
|
<h4 id="chat-name">Общий чат</h4>
|
||||||
<p>
|
<p>
|
||||||
<span class="online-status"></span> Онлайн
|
<span class="online-status"></span>
|
||||||
|
Онлайн
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<a href="#" id="chat-recrol">Свернуть чат</a>
|
<a href="#" id="hide-chat">Свернуть чат</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -189,15 +192,217 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<mdui-dialog id="profile-dialog" close-on-overlay-click close-on-esc>
|
|
||||||
<p>
|
|
||||||
Скоро будет...
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
<mdui-dialog id="profile-dialog" close-on-overlay-click close-on-esc>
|
||||||
<mdui-button id="profile-dialog-close">Закрыть</mdui-button>
|
<div class="content">
|
||||||
</p>
|
<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>
|
</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>
|
<script src="src/main.ts" type="module"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+135
-28
@@ -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 { loadMessages } from "./chat";
|
||||||
|
import { initializeProfile } from "./profile";
|
||||||
import type { Headers, ErrorResponse, User, LoginResponse, LoginRequest, RegisterRequest } from "./types";
|
import type { Headers, ErrorResponse, User, LoginResponse, LoginRequest, RegisterRequest } from "./types";
|
||||||
import { API_BASE_URL } from "./config";
|
import { API_BASE_URL } from "./config";
|
||||||
|
|
||||||
// Authentication and navigation handling
|
/**
|
||||||
|
* Current authenticated user information
|
||||||
|
* @type {User | null}
|
||||||
|
*/
|
||||||
export let currentUser: User | null = null;
|
export let currentUser: User | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT authentication token
|
||||||
|
* @type {string | null}
|
||||||
|
*/
|
||||||
export let authToken: string | null = 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) {
|
if (authToken) {
|
||||||
headers['Authorization'] = `Bearer ${authToken}`;
|
headers['Authorization'] = `Bearer ${authToken}`;
|
||||||
}
|
}
|
||||||
return headers;
|
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('login-form')!.style.display = 'flex';
|
||||||
document.getElementById('register-form')!.style.display = 'none';
|
document.getElementById('register-form')!.style.display = 'none';
|
||||||
document.getElementById('chat-interface')!.style.display = 'none';
|
document.getElementById('chat-interface')!.style.display = 'none';
|
||||||
clearAlerts();
|
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('login-form')!.style.display = 'none';
|
||||||
document.getElementById('register-form')!.style.display = 'flex';
|
document.getElementById('register-form')!.style.display = 'flex';
|
||||||
document.getElementById('chat-interface')!.style.display = 'none';
|
document.getElementById('chat-interface')!.style.display = 'none';
|
||||||
clearAlerts();
|
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('login-form')!.style.display = 'none';
|
||||||
document.getElementById('register-form')!.style.display = 'none';
|
document.getElementById('register-form')!.style.display = 'none';
|
||||||
document.getElementById('chat-interface')!.style.display = 'block';
|
document.getElementById('chat-interface')!.style.display = 'block';
|
||||||
loadMessages();
|
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('login-alerts')!.innerHTML = '';
|
||||||
document.getElementById('register-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 container = document.getElementById(containerId)!;
|
||||||
const alertDiv = document.createElement('div');
|
const alertDiv = document.createElement('div');
|
||||||
alertDiv.className = `alert alert-${type}`;
|
alertDiv.className = `alert alert-${type}`;
|
||||||
@@ -57,8 +110,14 @@ export function showAlert(containerId: string, message: string, type: "success"
|
|||||||
container.appendChild(alertDiv);
|
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();
|
e.preventDefault();
|
||||||
|
|
||||||
const usernameElement = document.getElementById('login-username') as HTMLInputElement;
|
const usernameElement = document.getElementById('login-username') as HTMLInputElement;
|
||||||
@@ -93,6 +152,7 @@ document.getElementById('login-form-element')!.addEventListener('submit', async
|
|||||||
currentUser = data.user;
|
currentUser = data.user;
|
||||||
showChat();
|
showChat();
|
||||||
loadMessages(); // Start loading messages
|
loadMessages(); // Start loading messages
|
||||||
|
initializeProfile(); // Initialize profile after login
|
||||||
} else {
|
} else {
|
||||||
const data: ErrorResponse = await response.json();
|
const data: ErrorResponse = await response.json();
|
||||||
showAlert('login-alerts', data.message || 'Неверное имя пользователя или пароль', 'danger');
|
showAlert('login-alerts', data.message || 'Неверное имя пользователя или пароль', 'danger');
|
||||||
@@ -100,10 +160,16 @@ document.getElementById('login-form-element')!.addEventListener('submit', async
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
showAlert('login-alerts', 'Ошибка соединения с сервером', 'danger');
|
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();
|
e.preventDefault();
|
||||||
|
|
||||||
const usernameElement = document.getElementById('register-username') as HTMLInputElement;
|
const usernameElement = document.getElementById('register-username') as HTMLInputElement;
|
||||||
@@ -162,10 +228,16 @@ document.getElementById('register-form-element')!.addEventListener('submit', asy
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
showAlert('register-alerts', 'Ошибка соединения с сервером', 'danger');
|
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 {
|
try {
|
||||||
await fetch(`${API_BASE_URL}/logout`, {
|
await fetch(`${API_BASE_URL}/logout`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -181,15 +253,50 @@ export async function logout() {
|
|||||||
clearAlerts();
|
clearAlerts();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load chat interface
|
/**
|
||||||
export function loadChat() {
|
* Loads the chat interface and initializes messaging
|
||||||
|
* @function loadChat
|
||||||
|
* @example
|
||||||
|
* loadChat();
|
||||||
|
*/
|
||||||
|
export function loadChat(): void {
|
||||||
showChat();
|
showChat();
|
||||||
loadMessages();
|
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
|
// For JWT, we don't have a persistent token on page load
|
||||||
// So we'll just show the login form
|
// So we'll just show the login form
|
||||||
showLogin();
|
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();
|
||||||
+151
-6
@@ -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 { getAuthHeaders, currentUser, authToken } from "./auth";
|
||||||
import { API_BASE_URL } from "./config";
|
import { API_BASE_URL } from "./config";
|
||||||
import { websocket } from "./websocket";
|
import { websocket } from "./websocket";
|
||||||
import type { Message, Messages, WebSocketMessage } from "./types";
|
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 messagesContainer = document.querySelector('.chat-messages') as HTMLElement;
|
||||||
const messageDiv = document.createElement('div');
|
const messageDiv = document.createElement('div');
|
||||||
messageDiv.classList.add("message");
|
messageDiv.classList.add("message");
|
||||||
@@ -19,20 +35,68 @@ export function addMessage(message: Message, isAuthor: boolean) {
|
|||||||
const messageInner = document.createElement('div');
|
const messageInner = document.createElement('div');
|
||||||
messageInner.classList.add('message-inner');
|
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) {
|
if (!isAuthor) {
|
||||||
const usernameDiv = document.createElement('div');
|
const usernameDiv = document.createElement('div');
|
||||||
usernameDiv.classList.add('message-username');
|
usernameDiv.classList.add('message-username');
|
||||||
usernameDiv.textContent = message.username;
|
usernameDiv.textContent = message.username;
|
||||||
|
|
||||||
|
// Add click handler to username
|
||||||
|
usernameDiv.style.cursor = 'pointer';
|
||||||
|
usernameDiv.addEventListener('click', () => {
|
||||||
|
showUserProfileDialog(message.username);
|
||||||
|
});
|
||||||
|
|
||||||
messageInner.appendChild(usernameDiv);
|
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');
|
const contentDiv = document.createElement('div');
|
||||||
|
contentDiv.classList.add('message-content');
|
||||||
contentDiv.textContent = message.content;
|
contentDiv.textContent = message.content;
|
||||||
messageInner.appendChild(contentDiv);
|
messageInner.appendChild(contentDiv);
|
||||||
|
|
||||||
const timeDiv = document.createElement('div');
|
const timeDiv = document.createElement('div');
|
||||||
timeDiv.classList.add('message-time');
|
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) {
|
if (isAuthor && message.is_read) {
|
||||||
const checkIcon = document.createElement('span');
|
const checkIcon = document.createElement('span');
|
||||||
@@ -44,11 +108,23 @@ export function addMessage(message: Message, isAuthor: boolean) {
|
|||||||
messageDiv.appendChild(messageInner);
|
messageDiv.appendChild(messageInner);
|
||||||
messagesContainer.appendChild(messageDiv);
|
messagesContainer.appendChild(messageDiv);
|
||||||
|
|
||||||
|
// Add right-click context menu
|
||||||
|
messageDiv.addEventListener('contextmenu', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
showContextMenu(message, e.clientX, e.clientY);
|
||||||
|
});
|
||||||
|
|
||||||
// Прокрутка к новому сообщению
|
// Прокрутка к новому сообщению
|
||||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
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`, {
|
fetch(`${API_BASE_URL}/get_messages`, {
|
||||||
headers: getAuthHeaders()
|
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 input = document.querySelector('.message-input') as HTMLInputElement;
|
||||||
const message = input.value.trim();
|
const message = input.value.trim();
|
||||||
|
|
||||||
@@ -109,3 +191,66 @@ document.getElementById('message-form')!.addEventListener('submit', (e) => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
sendMessage();
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
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`;
|
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";
|
export const PRODUCT_NAME: string = "FromChat";
|
||||||
+141
-4
@@ -7,7 +7,6 @@
|
|||||||
background-color: $color-dark-surface-container;
|
background-color: $color-dark-surface-container;
|
||||||
color: white;
|
color: white;
|
||||||
padding: 16px 16px;
|
padding: 16px 16px;
|
||||||
position: static;
|
|
||||||
justify-content: end;
|
justify-content: end;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
@@ -70,7 +69,7 @@
|
|||||||
background: $color-dark-surface-container;
|
background: $color-dark-surface-container;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
box-shadow: black 0px 0px 20px;
|
box-shadow: black 0 0 20px;
|
||||||
|
|
||||||
.chat-header-avatar {
|
.chat-header-avatar {
|
||||||
width: 45px;
|
width: 45px;
|
||||||
@@ -89,8 +88,7 @@
|
|||||||
|
|
||||||
h4 {
|
h4 {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
margin: 0;
|
margin: 0 0 0.2rem;
|
||||||
margin-bottom: 0.2rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
@@ -151,6 +149,29 @@
|
|||||||
max-width: 70%;
|
max-width: 70%;
|
||||||
position: relative;
|
position: relative;
|
||||||
width: max-content;
|
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 {
|
.message-inner {
|
||||||
padding: 0.8rem 1rem;
|
padding: 0.8rem 1rem;
|
||||||
@@ -158,6 +179,40 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
word-wrap: break-word;
|
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 {
|
.message-time {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
color: $color-dark-on-surface-variant;
|
color: $color-dark-on-surface-variant;
|
||||||
@@ -174,6 +229,7 @@
|
|||||||
|
|
||||||
&.sent {
|
&.sent {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
|
||||||
.message-inner {
|
.message-inner {
|
||||||
background-color: $color-dark-primary-container;
|
background-color: $color-dark-primary-container;
|
||||||
@@ -191,6 +247,12 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-bottom: 0.3rem;
|
margin-bottom: 0.3rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $color-dark-primary;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,3 +318,78 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,7 +89,6 @@
|
|||||||
|
|
||||||
img {
|
img {
|
||||||
$size: 45px;
|
$size: 45px;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
width: $size;
|
width: $size;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,10 +4,6 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mt-3 {
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert {
|
.alert {
|
||||||
padding: 0.8rem 1rem;
|
padding: 0.8rem 1rem;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
@use "auth";
|
@use "auth";
|
||||||
@use "chat";
|
@use "chat";
|
||||||
|
@use "profile";
|
||||||
|
@use "settings";
|
||||||
@use "panelchat";
|
@use "panelchat";
|
||||||
@use "common/animations";
|
@use "common/animations";
|
||||||
@use "common/components";
|
@use "common/components";
|
||||||
@@ -25,6 +27,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mdui-dialog {
|
mdui-dialog {
|
||||||
|
|
||||||
> *:first-child {
|
> *:first-child {
|
||||||
margin-block-start: 0;
|
margin-block-start: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { showLogin } from "./auth";
|
||||||
import { PRODUCT_NAME } from "./config";
|
import { PRODUCT_NAME } from "./config";
|
||||||
|
|
||||||
showLogin();
|
showLogin();
|
||||||
|
|
||||||
document.getElementById("productname")!.textContent = PRODUCT_NAME;
|
document.getElementById("productname")!.textContent = PRODUCT_NAME;
|
||||||
document.title = PRODUCT_NAME;
|
document.title = PRODUCT_NAME;
|
||||||
+62
-28
@@ -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 type { Dialog } from "mdui/components/dialog";
|
||||||
|
import { loadProfilePicture } from "./profile/upload";
|
||||||
|
|
||||||
// сварачивание и разворачивание чата
|
// сварачивание и разворачивание чата
|
||||||
const but = document.getElementById('chat-recrol')!;
|
const chatCollapseBtn = document.getElementById('hide-chat')!;
|
||||||
const but_list1 = document.getElementById('chat1but')!;
|
const chat1 = document.getElementById('chat-list-chat-1')!;
|
||||||
const but_list2 = document.getElementById('chat1but2')!;
|
const chat2 = document.getElementById('chat-list-chat-2')!;
|
||||||
const cont1 = document.getElementById('conteinerchat')!;
|
const chatInner = document.getElementById('chat-inner')!;
|
||||||
const namechat = document.getElementById('namechat')!;
|
const chatName = document.getElementById('chat-name')!;
|
||||||
|
const profileButton = document.getElementById('profile-open')!;
|
||||||
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 dialog = document.getElementById("profile-dialog") as Dialog;
|
const dialog = document.getElementById("profile-dialog") as Dialog;
|
||||||
const dialogClose = document.getElementById("profile-dialog-close")!;
|
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", () => {
|
/**
|
||||||
|
* 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;
|
dialog.open = false;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setupChatCollapse();
|
||||||
|
setupChatSwitching();
|
||||||
|
setupProfileDialog();
|
||||||
@@ -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);
|
|
||||||
+12
-2
@@ -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 './css/style.scss';
|
||||||
import "mdui/mdui.css";
|
import "mdui/mdui.css";
|
||||||
|
|
||||||
import "./links";
|
import "./utils/material";
|
||||||
import "./material";
|
|
||||||
import "./chat";
|
import "./chat";
|
||||||
|
import "./settings";
|
||||||
import "./leftpanel";
|
import "./leftpanel";
|
||||||
import "./init";
|
import "./init";
|
||||||
|
import "./profile";
|
||||||
|
import "./message-context-menu";
|
||||||
|
import "./user-profile-dialog";
|
||||||
@@ -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();
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -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}
|
export type Headers = {[x: string]: string}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API error response structure
|
||||||
|
* @interface ErrorResponse
|
||||||
|
* @property {string} message - Error message from the server
|
||||||
|
*/
|
||||||
export interface ErrorResponse {
|
export interface ErrorResponse {
|
||||||
message: string;
|
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
|
// 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 {
|
export interface Message {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
content: string;
|
content: string;
|
||||||
is_read: boolean;
|
is_read: boolean;
|
||||||
|
is_edited: boolean;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
|
profile_picture?: string;
|
||||||
|
reply_to?: Message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collection of messages
|
||||||
|
* @interface Messages
|
||||||
|
* @property {Message[]} messages - Array of message objects
|
||||||
|
*/
|
||||||
export interface Messages {
|
export interface Messages {
|
||||||
messages: Message[];
|
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 {
|
export interface User {
|
||||||
id: number;
|
id: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
last_seen: string;
|
last_seen: string;
|
||||||
online: boolean;
|
online: boolean;
|
||||||
username: string;
|
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
|
// API models
|
||||||
// ----------
|
// ----------
|
||||||
|
|
||||||
// Requests
|
// Requests
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login request structure
|
||||||
|
* @interface LoginRequest
|
||||||
|
* @property {string} username - Username for authentication
|
||||||
|
* @property {string} password - Password for authentication
|
||||||
|
*/
|
||||||
export interface LoginRequest {
|
export interface LoginRequest {
|
||||||
username: string;
|
username: string;
|
||||||
password: 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 {
|
export interface RegisterRequest {
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -44,6 +136,13 @@ export interface RegisterRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Responses
|
// Responses
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login response structure
|
||||||
|
* @interface LoginResponse
|
||||||
|
* @property {User} user - User information
|
||||||
|
* @property {string} token - JWT authentication token
|
||||||
|
*/
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
user: User;
|
user: User;
|
||||||
token: string;
|
token: string;
|
||||||
@@ -53,6 +152,14 @@ export interface LoginResponse {
|
|||||||
// WebSocket types
|
// 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 {
|
export interface WebSocketMessage {
|
||||||
type: string;
|
type: string;
|
||||||
credentials?: WebSocketCredentials;
|
credentials?: WebSocketCredentials;
|
||||||
@@ -60,11 +167,23 @@ export interface WebSocketMessage {
|
|||||||
error?: WebSocketError;
|
error?: WebSocketError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSocket error structure
|
||||||
|
* @interface WebSocketError
|
||||||
|
* @property {number} code - Error code
|
||||||
|
* @property {string} detail - Error detail message
|
||||||
|
*/
|
||||||
export interface WebSocketError {
|
export interface WebSocketError {
|
||||||
code: number;
|
code: number;
|
||||||
detail: string;
|
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 {
|
export interface WebSocketCredentials {
|
||||||
scheme: string;
|
scheme: string;
|
||||||
credentials: string;
|
credentials: string;
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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/tabs';
|
||||||
import 'mdui/components/tab';
|
import 'mdui/components/tab';
|
||||||
import 'mdui/components/tab-panel';
|
import 'mdui/components/tab-panel';
|
||||||
@@ -9,6 +16,9 @@ import 'mdui/components/fab';
|
|||||||
import 'mdui/components/dialog';
|
import 'mdui/components/dialog';
|
||||||
import 'mdui/components/button';
|
import 'mdui/components/button';
|
||||||
import 'mdui/components/text-field';
|
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';
|
import { setColorScheme } from 'mdui/functions/setColorScheme.js';
|
||||||
|
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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
@@ -1,14 +1,30 @@
|
|||||||
import { currentUser } from "./auth";
|
/**
|
||||||
import { addMessage } from "./chat";
|
* @fileoverview WebSocket connection management for real-time chat
|
||||||
import { API_FULL_BASE_URL } from "./config";
|
* @description Handles WebSocket connections, message processing, and auto-reconnection
|
||||||
import type { WebSocketMessage, Message } from "./types";
|
* @author Cursor
|
||||||
import { delay } from "./utils";
|
* @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`);
|
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
|
// Initialization
|
||||||
@@ -16,13 +32,7 @@ export let websocket = create();
|
|||||||
|
|
||||||
websocket.addEventListener("message", (e) => {
|
websocket.addEventListener("message", (e) => {
|
||||||
const message: WebSocketMessage = JSON.parse(e.data);
|
const message: WebSocketMessage = JSON.parse(e.data);
|
||||||
switch (message.type) {
|
handleWebSocketMessage(message);
|
||||||
case "newMessage": {
|
|
||||||
const newMessage: Message = message.data;
|
|
||||||
addMessage(newMessage, newMessage.username == currentUser!.username);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
websocket.addEventListener("error", async () => {
|
websocket.addEventListener("error", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user