Add profile

This commit is contained in:
2025-08-22 15:51:32 +03:00
Unverified
parent 803f685180
commit deb02a5e95
25 changed files with 885 additions and 116 deletions
+1 -1
View File
@@ -376,7 +376,7 @@ pyrightconfig.json
# Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option) # 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
+3 -2
View File
@@ -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")
@@ -17,4 +17,5 @@ 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)
+2 -2
View File
@@ -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
+4
View File
@@ -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)
+2
View File
@@ -15,6 +15,7 @@ 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)
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)
@@ -56,6 +57,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
+3 -1
View File
@@ -3,4 +3,6 @@ fastapi[standard]>=0.116.1
pydantic>=2.11.7 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
+2 -1
View File
@@ -15,7 +15,8 @@ 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 "username": msg.author.username,
"profile_picture": msg.author.profile_picture
} }
async def get_messages_inner(db: Session): async def get_messages_inner(db: Session):
+98
View File
@@ -0,0 +1,98 @@
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
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,
"online": current_user.online,
"last_seen": current_user.last_seen,
"created_at": current_user.created_at
}
+43 -21
View File
@@ -193,19 +193,50 @@
</div> </div>
<mdui-dialog id="profile-dialog" close-on-overlay-click close-on-esc> <mdui-dialog id="profile-dialog" close-on-overlay-click close-on-esc>
<div class="header-top"> <div class="profile-dialog-content">
<div class="left"> <div class="header-top">
<img id="profile-picture" src="./src/images/default-avatar.png" alt="Ваше фото" /> <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> </div>
<div class="right">
<div id="profile-username">Loading...</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>
</div> </div>
<p>
<mdui-button id="profile-dialog-close">Закрыть</mdui-button>
</p>
</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> <mdui-dialog id="settings-dialog" close-on-overlay-click close-on-esc fullscreen>
<div class="fullscreen-wrapper"> <div class="fullscreen-wrapper">
<div id="settings-dialog-inner"> <div id="settings-dialog-inner">
@@ -215,8 +246,7 @@
</div> </div>
<div id="settings-menu"> <div id="settings-menu">
<mdui-list> <mdui-list>
<mdui-list-item icon="account_circle--filled" rounded active>Профиль</mdui-list-item> <mdui-list-item icon="notifications--filled" rounded active>Уведомления</mdui-list-item>
<mdui-list-item icon="notifications--filled" rounded>Уведомления</mdui-list-item>
<mdui-list-item icon="palette--filled" rounded>Внешний вид</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="security--filled" rounded>Безопасность</mdui-list-item>
<mdui-list-item icon="language--filled" rounded>Язык</mdui-list-item> <mdui-list-item icon="language--filled" rounded>Язык</mdui-list-item>
@@ -225,15 +255,7 @@
<mdui-list-item icon="info--filled" rounded>О приложении</mdui-list-item> <mdui-list-item icon="info--filled" rounded>О приложении</mdui-list-item>
</mdui-list> </mdui-list>
<div class="screen"> <div class="screen">
<div id="profile-settings" class="settings-panel active"> <div id="notifications-settings" class="settings-panel active">
<h3>Профиль</h3>
<mdui-text-field label="Имя пользователя" variant="outlined" value="user123"></mdui-text-field>
<mdui-text-field label="Email" variant="outlined" type="email" value="user@example.com"></mdui-text-field>
<mdui-text-field label="О себе" variant="outlined" multiline rows="3" placeholder="Расскажите о себе..."></mdui-text-field>
<mdui-button>Сохранить изменения</mdui-button>
</div>
<div id="notifications-settings" class="settings-panel">
<h3>Уведомления</h3> <h3>Уведомления</h3>
<mdui-switch checked>Новые сообщения</mdui-switch> <mdui-switch checked>Новые сообщения</mdui-switch>
<mdui-switch checked>Звуковые уведомления</mdui-switch> <mdui-switch checked>Звуковые уведомления</mdui-switch>
@@ -288,7 +310,7 @@
<div id="about-settings" class="settings-panel"> <div id="about-settings" class="settings-panel">
<h3>О приложении</h3> <h3>О приложении</h3>
<p>Версия: 1.0.0</p> <p>Версия: 1.0.0</p>
<p>© 2024 Boost Chat. Все права защищены.</p> <p>© 2024 From Chat. Все права защищены.</p>
<mdui-button variant="outlined">Политика конфиденциальности</mdui-button> <mdui-button variant="outlined">Политика конфиденциальности</mdui-button>
<mdui-button variant="outlined">Условия использования</mdui-button> <mdui-button variant="outlined">Условия использования</mdui-button>
</div> </div>
+9 -4
View File
@@ -1,4 +1,5 @@
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";
@@ -8,10 +9,13 @@ export let authToken: string | null = null;
// Helper function to get auth headers // Helper function to get auth headers
export function getAuthHeaders(): Headers { export function getAuthHeaders(json: boolean = true): Headers {
const headers: Headers = { const headers: Headers = {};
'Content-Type': 'application/json',
}; if (json) {
headers["Content-Type"] = "application/json";
}
if (authToken) { if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`; headers['Authorization'] = `Bearer ${authToken}`;
} }
@@ -93,6 +97,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');
+16
View File
@@ -19,6 +19,22 @@ 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';
};
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');
+18
View File
@@ -151,6 +151,23 @@
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;
}
}
.message-inner { .message-inner {
padding: 0.8rem 1rem; padding: 0.8rem 1rem;
@@ -174,6 +191,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;
+92 -8
View File
@@ -2,33 +2,117 @@
@use "common/material" as *; @use "common/material" as *;
#profile-dialog { #profile-dialog {
.profile-dialog-content {
display: flex;
flex-direction: column;
gap: 24px;
min-width: 400px;
}
.header-top { .header-top {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center;
gap: 16px; gap: 16px;
position: relative; position: relative;
padding-bottom: 16px;
border-bottom: 1px solid $color-dark-outline;
.left { .profile-picture-container {
position: relative;
$size: 70px; $size: 70px;
width: $size; width: $size;
height: $size; height: $size;
flex-shrink: 0;
#profile-picture { #profile-picture {
width: $size; width: $size;
height: $size; height: $size;
border-radius: 50%; border-radius: 50%;
object-fit: cover;
}
.upload-overlay {
position: absolute;
bottom: 0;
right: 0;
width: 28px;
height: 28px;
cursor: pointer;
} }
} }
.right { mdui-text-field {
display: flex; flex: 1;
flex-direction: column; }
justify-content: center; }
#profile-username { #profile-form {
font-size: larger; 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;
} }
} }
} }
}
// 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;
}
} }
-15
View File
@@ -1,15 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 960 960" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M864.189 226.74C858.953 322.858 754.737 374.315 695.538 438.255C587.28 548.523 471.983 651.236 357.786 755.237C336.782 774.521 312.167 786.817 286.061 797.151C218.876 823.397 152.288 851.277 84.9569 877.096C33.4679 887.917 33.5929 837.125 51.9969 803.274C82.4819 737.544 111.888 671.506 134.694 602.632C140.147 588.119 146.394 574.096 158.351 563.118C316.129 418.731 472.524 272.847 629.387 127.473C671.701 84.4735 735.485 58.8855 787.599 101.237C828.067 131.816 858.407 175.705 866.226 226.252C865.547 226.415 864.868 226.577 864.189 226.74ZM577.502 375.208C475.229 474.325 371.382 574.967 267.068 676.062C282.355 693.01 298.422 710.233 316.028 726.33C423.063 629.117 532.768 534.003 635.414 429.155C615.93 411.005 596.96 393.333 577.502 375.208ZM511.033 300.112C405.345 398.52 300.168 496.453 194.564 594.783C208.416 610.826 222.61 627.264 238.338 645.479C342.681 543.636 445.97 442.823 549.75 341.53C536.093 326.92 523.852 313.825 511.033 300.112ZM740.017 332.746C771.509 304.027 835.255 253.663 812.494 206.585C746.706 89.9645 700.949 116.001 619.406 199.264C659.925 244.107 699.781 288.217 740.017 332.746ZM274.408 749.697C240.205 713.159 206.909 677.589 174.655 643.132C159.169 679.946 143.72 716.957 127.99 753.849C124.389 762.296 118.674 770.287 129.712 777.657C134.223 784.437 138.046 793.07 142.584 800.069C186.793 783.177 230.302 766.551 274.408 749.697ZM544.049 269.928C584.723 313.301 624.972 356.22 665.379 399.309C680.179 385.866 693.882 373.42 707.709 360.86C667.185 317.314 626.926 274.051 586.479 230.588C571.828 244.172 558.447 256.578 544.049 269.928Z" fill="#000000"/>
<path d="M638.673 709.515C639.343 676.351 677.174 677.225 692.387 700.363C726.843 744.221 760.844 788.44 794.87 832.637C817.027 858.762 782.4 890.608 758.307 866.545C717.849 825.832 684.536 778.554 648.479 734.013C643.193 727.217 640.425 718.463 636.496 710.613C637.221 710.246 637.947 709.88 638.673 709.515Z" fill="#000000"/>
<path d="M151.066 137.732C153.494 108.423 188.706 102.337 203.647 124.81C235.905 172.013 273.59 216.181 301.895 265.799C313.411 289.311 283.416 311.351 264.894 292.57C231.379 256.708 205.952 213.945 175.929 175.18C165.617 163.929 156.995 151.904 151.066 137.732Z" fill="#000000"/>
<path d="M891.911 614.01C862.482 608.358 833.494 602.751 804.26 596.663C802.393 596.28 800.531 595.828 798.711 595.265C761.812 585.318 773.326 533.411 810.404 541.287C840.924 548.38 871.139 556.895 901.224 565.701C912.077 568.878 919.347 576.915 919.717 589.183C920.689 605.42 906.16 612.951 891.911 614.01Z" fill="#000000"/>
<path d="M149.196 434.169C123.591 424.884 58.974 428.513 68.036 388.304C71.15 376.308 84.587 367.941 98.537 370.701C119.14 374.778 139.766 379.065 159.972 384.727C193.482 395.201 179.49 434.51 149.196 434.169Z" fill="#000000"/>
<path d="M492.843 843.595C492.129 822.836 489.637 787.689 517.329 787.075C530.926 786.658 542.651 796.779 542.97 812.531C541.532 834.435 545.506 868.675 534.546 887.253C523.096 905.866 493.562 897.454 492.893 875.571C492.58 864.936 493.061 854.249 492.896 843.595C492.878 843.594 492.86 843.595 492.843 843.595Z" fill="#000000"/>
<path d="M486.449 126.168C492.884 164.558 451.635 176.738 440.787 145.638C436.983 126.526 434.554 106.662 433.855 87.1565C434.131 56.1805 477.956 53.1725 482.89 83.7265C484.932 97.7525 485.576 112.019 486.967 126.142C486.794 126.151 486.621 126.159 486.449 126.168Z" fill="#000000"/>
<path d="M577.5 375.203C596.958 393.329 615.928 411 635.411 429.15C532.766 533.997 423.06 629.111 316.026 726.324C298.428 710.235 282.345 692.996 267.066 676.056C371.38 574.962 475.228 474.319 577.5 375.203Z" fill="white"/>
<path d="M511.031 300.109C523.849 313.822 536.091 326.917 549.748 341.527C445.968 442.82 342.68 543.633 238.336 645.476C222.608 627.26 208.415 610.823 194.562 594.78C300.166 496.45 405.344 398.517 511.031 300.109Z" fill="white"/>
<path d="M740.018 332.744C699.782 288.215 659.926 244.106 619.406 199.263C701.068 115.89 746.72 90.0456 812.493 206.586C835.234 253.723 771.572 303.932 740.018 332.744Z" fill="#000000"/>
<path d="M274.409 749.697C230.303 766.551 186.794 783.177 142.586 800.07C138.05 793.075 134.222 784.437 129.714 777.657C118.675 770.288 124.39 762.297 127.991 753.85C143.72 716.958 159.169 679.947 174.656 643.133C206.91 677.589 240.207 713.159 274.409 749.697Z" fill="white"/>
<path d="M544.051 269.926C558.449 256.577 571.83 244.17 586.481 230.586C626.928 274.049 667.187 317.312 707.711 360.858C693.884 373.418 680.181 385.864 665.381 399.307C624.974 356.218 584.725 313.298 544.051 269.926Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 4.7 KiB

+3
View File
@@ -1,4 +1,5 @@
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 but = document.getElementById('chat-recrol')!;
@@ -29,6 +30,8 @@ const dialogClose = document.getElementById("profile-dialog-close")!;
butprofile.addEventListener('click', () => { butprofile.addEventListener('click', () => {
dialog.open = true; dialog.open = true;
// Load profile picture when dialog opens
loadProfilePicture();
}); });
dialogClose.addEventListener("click", () => { dialogClose.addEventListener("click", () => {
+37
View File
@@ -0,0 +1,37 @@
export type NotificationType = 'success' | 'error';
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);
}
export function showSuccess(message: string): void {
showNotification(message, 'success');
}
export function showError(message: string): void {
showNotification(message, 'error');
}
+28 -60
View File
@@ -1,63 +1,31 @@
// const fileInput = document.getElementById('fileInput') as HTMLInputElement; import type { Dialog } from "mdui/components/dialog";
// const preview = document.getElementById('preview') as HTMLImageElement; import { loadProfileData } from './profile/editor';
// const preview1 = document.getElementById('preview1') as HTMLImageElement; import { loadProfilePicture, initializeProfileUpload } from "./profile/upload";
// const button = document.getElementById('uploadButton')!; import { initializeProfileEditor } from './profile/editor';
// if (fileInput && preview && button) { // Handle profile form submission
// // при клике по кнопке откроем диалог выбора файла const form = document.getElementById("profile-form")!;
// button.addEventListener('click', () => { const dialog = document.getElementById("profile-dialog") as Dialog;
// fileInput.click();
// });
// // при выборе файла — показываем его form.addEventListener("submit", async (e) => {
// fileInput.addEventListener('change', () => { e.preventDefault();
// const file: File | null = fileInput.files ? fileInput.files[0] : null;
// if (file) { // TODO: Process form data if needed
// const reader: FileReader = new FileReader(); // For now, just close the dialog
// reader.onload = (e: ProgressEvent<FileReader>) => { dialog.open = false;
// if (e.target && typeof e.target.result === 'string') { });
// preview.src = e.target.result;
// preview1.src = e.target.result;
// preview.style.display = 'block';
// }
// };
// reader.readAsDataURL(file);
// }
// });
// }
// const textDiv = document.getElementById('text-nik')!; // Initialize profile functionality after login
// const input = document.getElementById('nik') as HTMLInputElement; export function initializeProfile(): void {
// const button1 = document.getElementById('change-name')!; // Initialize profile modules
initializeProfileUpload();
// button1.addEventListener('click', () => { initializeProfileEditor();
// if (input.style.display === 'none') {
// // Переводим в режим редактирования // Load profile data
// input.value = textDiv.textContent || ''; Promise.all([
// textDiv.style.display = 'none'; loadProfilePicture(),
// input.style.display = 'flex'; loadProfileData()
// } else { ]).catch(error => {
// // Сохраняем изменения console.error('Error initializing profile:', error);
// textDiv.textContent = input.value; });
// textDiv.style.display = 'flex'; }
// input.style.display = 'none';
// }
// });
// const textDiv2 = document.getElementById('text-discr')!;
// const input2 = document.getElementById('discr') as HTMLInputElement;
// const button2 = document.getElementById('change-discription')!;
// button2.addEventListener('click', () => {
// if (input2.style.display === 'none') {
// // Переводим в режим редактирования
// input2.value = textDiv2.textContent || '';
// textDiv2.style.display = 'none';
// input2.style.display = 'flex';
// } else {
// // Сохраняем изменения
// textDiv2.textContent = input2.value;
// textDiv2.style.display = 'flex';
// input2.style.display = 'none';
// }
// });
+118
View File
@@ -0,0 +1,118 @@
# Profile Module Structure
This directory contains the modularized profile functionality for the FromChat application.
## Structure
```
profile/
├── types.ts # Type definitions
├── notification.ts # Notification system
├── image-cropper.ts # Image cropping functionality
├── profile-service.ts # API service layer
├── profile-upload.ts # Upload management
├── profile-editor.ts # Profile editing functionality
└── README.md # This file
```
## Modules
### `types.ts`
Contains all TypeScript interfaces and types used across the profile module:
- `ProfileData` - User profile data structure
- `UploadResponse` - API response for uploads
- `NotificationType` - Notification types
- `CropPosition` - Image cropping position
- `DragStart` - Drag operation start position
### `notification.ts`
Top-level notification functions for displaying success/error messages:
- `showSuccess(message: string)` - Show success notification
- `showError(message: string)` - Show error notification
### `image-cropper.ts`
Canvas-based image cropper for profile pictures:
- `ImageCropper` - Main cropper class with touch/mouse support
- Handles circular cropping with drag functionality
### `profile-service.ts`
API service layer for profile operations:
- `loadProfile()` - Load user profile data
- `uploadProfilePicture(file: Blob)` - Upload profile picture
- `updateProfile(data: Partial<ProfileData>)` - Update profile data
### `profile-upload.ts`
Manages profile picture upload workflow:
- `loadProfilePicture()` - Load and display profile picture
- Global variables and event listeners for upload UI
- Handles file selection, cropping, and upload
### `profile-editor.ts`
Manages profile text editing (nickname, description):
- `loadProfileData()` - Load profile text data
- `setNicknameValue(value: string)` - Set nickname value
- `setDescriptionValue(value: string)` - Set description value
- `getNicknameValue()` - Get current nickname
- `getDescriptionValue()` - Get current description
- Global event listeners for edit buttons
- Supports Enter to save, Escape to cancel
## Usage
### Direct Imports
```typescript
// Import specific functions from each module
import { loadProfile, uploadProfilePicture, updateProfile } from './profile/profile-service';
import { loadProfilePicture } from './profile/profile-upload';
import { loadProfileData, setNicknameValue } from './profile/profile-editor';
import { showSuccess, showError } from './profile/notification';
import { ImageCropper } from './profile/image-cropper';
```
### Loading Profile Data
```typescript
// Load profile picture
await loadProfilePicture();
// Load profile text data
await loadProfileData();
```
### Uploading Profile Picture
The upload process is handled automatically by the global event listeners in `profile-upload.ts` when users interact with the upload UI.
### Editing Profile Text
The editing process is handled automatically by the global event listeners in `profile-editor.ts` when users interact with the edit buttons.
### Showing Notifications
```typescript
showSuccess('Operation completed successfully!');
showError('Something went wrong!');
```
### API Operations
```typescript
// Load profile data
const profileData = await loadProfile();
// Update profile
const success = await updateProfile({ nickname: 'New Name' });
// Upload profile picture
const result = await uploadProfilePicture(blob);
```
## Benefits of This Structure
1. **Separation of Concerns**: Each module has a single responsibility
2. **Reusability**: Functions can be used independently
3. **Maintainability**: Easier to find and fix issues
4. **Testability**: Each function can be tested in isolation
5. **Type Safety**: Strong TypeScript typing throughout
6. **Top-level Functions**: Simple function calls instead of class instances
7. **Direct Imports**: Import only what you need from specific modules
8. **No Index File**: Direct imports reduce complexity and improve tree shaking
## Migration from Original Files
The original `profile.ts` and `profile-upload.ts` files have been refactored to use this modular structure. The functionality remains the same, but it's now better organized and more maintainable.
+55
View File
@@ -0,0 +1,55 @@
import { getAuthHeaders } from '../auth';
import type { ProfileData, UploadResponse } from './types';
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;
}
}
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;
}
}
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;
}
}
+85
View File
@@ -0,0 +1,85 @@
import { updateProfile } from './api';
import { loadProfile } from './api';
import { showSuccess, showError } from '../notification';
import type { TextField } from 'mdui/components/text-field';
// DOM elements
let profileForm: HTMLElement;
let nicknameField: TextField; // MDUI TextField
let descriptionField: TextField; // MDUI TextField
let isInitialized = false;
export function setUsernameValue(value: string): void {
if (nicknameField && nicknameField.value !== undefined) {
nicknameField.value = value;
}
}
export function setDescriptionValue(value: string): void {
if (descriptionField && descriptionField.value !== undefined) {
descriptionField.value = value;
}
}
export function getUsernameValue(): string {
if (nicknameField && nicknameField.value !== undefined) {
return nicknameField.value;
}
return '';
}
export function getDescriptionValue(): string {
if (descriptionField && descriptionField.value !== undefined) {
return descriptionField.value;
}
return '';
}
export async function loadProfileData(): Promise<void> {
const userData = await loadProfile();
if (userData) {
if (userData.nickname) {
setUsernameValue(userData.nickname);
}
if (userData.description) {
setDescriptionValue(userData.description);
}
}
}
// Setup form submission handler
function setupFormHandler(): void {
if (isInitialized) return;
// Get DOM elements
profileForm = document.getElementById('profile-form')!;
nicknameField = document.getElementById('username-field') as any; // MDUI TextField
descriptionField = document.getElementById('description-field') as any; // MDUI TextField
profileForm.addEventListener('submit', async (e) => {
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('Ошибка при обновлении профиля');
}
}
});
isInitialized = true;
}
// Initialize editor functionality
export function initializeProfileEditor(): void {
setupFormHandler();
}
+131
View File
@@ -0,0 +1,131 @@
import type { Size2D } from "../types";
export class ImageCropper {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
private image!: HTMLImageElement;
private cropSize: number = 200;
private isDragging: boolean = false;
private dragStart: Size2D = { x: 0, y: 0 };
private cropPosition: Size2D = { x: 0, y: 0 };
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();
}
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));
}
private onMouseDown(e: MouseEvent): void {
this.isDragging = true;
this.dragStart = { x: e.clientX, y: e.clientY };
}
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();
}
private onMouseUp(): void {
this.isDragging = false;
}
private onTouchStart(e: TouchEvent): void {
e.preventDefault();
const touch = e.touches[0];
this.isDragging = true;
this.dragStart = { x: touch.clientX, y: touch.clientY };
}
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();
}
private onTouchEnd(): void {
this.isDragging = false;
}
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);
});
}
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();
}
getCroppedImage(): string {
return this.canvas.toDataURL('image/jpeg', 0.8);
}
destroy(): void {
if (this.canvas.parentNode) {
this.canvas.parentNode.removeChild(this.canvas);
}
}
}
+9
View File
@@ -0,0 +1,9 @@
export interface ProfileData {
profile_picture?: string;
nickname?: string;
description?: string;
}
export interface UploadResponse {
profile_picture_url: string;
}
+120
View File
@@ -0,0 +1,120 @@
import type { Dialog } from "mdui/components/dialog";
import { ImageCropper } from './image-cropper';
import { uploadProfilePicture } from './api';
import { loadProfile } from './api';
import { showSuccess, showError } from '../notification';
// Global variables
let cropper: ImageCropper | null = null;
let isInitialized = false;
// DOM elements
let cropperDialog: Dialog;
let fileInput: HTMLInputElement;
let uploadBtn: HTMLElement;
let cropSaveBtn: HTMLElement;
let cropCancelBtn: HTMLElement;
let cropperCloseBtn: HTMLElement;
let cropperArea: HTMLElement;
// Setup event listeners
function setupEventListeners(): void {
if (isInitialized) return;
// Get DOM elements
cropperDialog = document.getElementById('cropper-dialog') as Dialog;
fileInput = document.getElementById('pfp-file-input') as HTMLInputElement;
uploadBtn = document.getElementById('upload-pfp-btn')!;
cropSaveBtn = document.getElementById('crop-save')!;
cropCancelBtn = document.getElementById('crop-cancel')!;
cropperCloseBtn = document.getElementById('cropper-close')!;
cropperArea = document.getElementById('cropper-area')!;
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;
}
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;
}
function closeCropper(): void {
cropperDialog.open = false;
cropperArea.innerHTML = '';
if (cropper) {
cropper.destroy();
cropper = null;
}
fileInput.value = '';
}
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('Ошибка при загрузке фото');
}
}
export async function loadProfilePicture(): Promise<void> {
const userData = await loadProfile();
if (userData?.profile_picture) {
const profilePicture = document.getElementById('profile-picture') as HTMLImageElement;
profilePicture.src = userData.profile_picture + '?t=' + Date.now(); // Cache bust
}
}
// Initialize upload functionality
export function initializeProfileUpload(): void {
setupEventListeners();
}
-1
View File
@@ -15,7 +15,6 @@ function initializeSettings() {
// Create a mapping between list items and their corresponding panels // Create a mapping between list items and their corresponding panels
const panelMapping = { const panelMapping = {
'Профиль': 'profile-settings',
'Уведомления': 'notifications-settings', 'Уведомления': 'notifications-settings',
'Внешний вид': 'appearance-settings', 'Внешний вид': 'appearance-settings',
'Безопасность': 'security-settings', 'Безопасность': 'security-settings',
+6
View File
@@ -4,6 +4,11 @@ export interface ErrorResponse {
message: string; message: string;
} }
export interface Size2D {
x: number;
y: number;
}
// App types // App types
export interface Message { export interface Message {
@@ -12,6 +17,7 @@ export interface Message {
content: string; content: string;
is_read: boolean; is_read: boolean;
timestamp: string; timestamp: string;
profile_picture?: string;
} }
export interface Messages { export interface Messages {