diff --git a/.gitignore b/.gitignore index f0cb9db..a7c6771 100644 --- a/.gitignore +++ b/.gitignore @@ -376,7 +376,7 @@ pyrightconfig.json # Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option) -instance +data .vite *.db package-lock.json \ No newline at end of file diff --git a/backend/app.py b/backend/app.py index 4476f9a..9b7a6ae 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,7 +1,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from routes import account, messaging +from routes import account, messaging, profile # Инициализация FastAPI app = FastAPI(title="PixelChat") @@ -17,4 +17,5 @@ app.add_middleware( # Routes app.include_router(account.router) -app.include_router(messaging.router) \ No newline at end of file +app.include_router(messaging.router) +app.include_router(profile.router) \ No newline at end of file diff --git a/backend/constants.py b/backend/constants.py index d9a47c3..e3e8bb7 100644 --- a/backend/constants.py +++ b/backend/constants.py @@ -1,4 +1,4 @@ -DATABASE_URL = "sqlite:///./pixelchat.db" -JWT_SECRET_KEY = "pixelchat-jwt-secret" +DATABASE_URL = "sqlite:///./data/database.db" +JWT_SECRET_KEY = "fromchat-jwt-secret" JWT_ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_HOURS = 24 \ No newline at end of file diff --git a/backend/db.py b/backend/db.py index 834a613..1708283 100644 --- a/backend/db.py +++ b/backend/db.py @@ -1,6 +1,10 @@ +import os from sqlalchemy.orm import sessionmaker from sqlalchemy import create_engine from constants import DATABASE_URL +# Ensure data directory exists +os.makedirs("data", exist_ok=True) + engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) \ No newline at end of file diff --git a/backend/models.py b/backend/models.py index c193247..00e7f90 100644 --- a/backend/models.py +++ b/backend/models.py @@ -15,6 +15,7 @@ class User(Base): id = Column(Integer, primary_key=True, index=True) username = Column(String(50), unique=True, nullable=False, index=True) password_hash = Column(String(200), nullable=False) + profile_picture = Column(String(255), nullable=True) online = Column(Boolean, default=False) last_seen = Column(DateTime, default=datetime.now) created_at = Column(DateTime, default=datetime.now) @@ -56,6 +57,7 @@ class MessageResponse(BaseModel): is_author: bool is_read: bool username: str + profile_picture: str | None class Config: from_attributes = True diff --git a/backend/requirements.txt b/backend/requirements.txt index 158eacb..326b790 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,4 +3,6 @@ fastapi[standard]>=0.116.1 pydantic>=2.11.7 sqlalchemy>=2.0.43 bcrypt>=4.3.0 -websockets>=15.0.1 \ No newline at end of file +websockets>=15.0.1 +Pillow>=10.0.0 +python-multipart>=0.0.6 \ No newline at end of file diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index 2e93e1b..9e2bafb 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -15,7 +15,8 @@ def convert_message(msg: Message) -> dict: "content": msg.content, "timestamp": msg.timestamp.isoformat(), "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): diff --git a/backend/routes/profile.py b/backend/routes/profile.py new file mode 100644 index 0000000..a82dfca --- /dev/null +++ b/backend/routes/profile.py @@ -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 + } diff --git a/frontend/index.html b/frontend/index.html index dc6280e..93cd4aa 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -193,19 +193,50 @@ -
-
- Ваше фото +
+
+
+ Ваше фото + + +
+
-
-
Loading...
+ +
+ +
+ Сохранить изменения + Закрыть +
- -

- Закрыть -

+ + + +
+
+

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

+ +
+
+
+
+
+ Отмена + Сохранить +
+
+
+
@@ -215,8 +246,7 @@
- Профиль - Уведомления + Уведомления Внешний вид Безопасность Язык @@ -225,15 +255,7 @@ О приложении
-
-

Профиль

- - - - Сохранить изменения -
- -
+

Уведомления

Новые сообщения Звуковые уведомления @@ -288,7 +310,7 @@

О приложении

Версия: 1.0.0

-

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

+

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

Политика конфиденциальности Условия использования
diff --git a/frontend/src/auth.ts b/frontend/src/auth.ts index d869a72..7a355da 100644 --- a/frontend/src/auth.ts +++ b/frontend/src/auth.ts @@ -1,4 +1,5 @@ import { loadMessages } from "./chat"; +import { initializeProfile } from "./profile"; import type { Headers, ErrorResponse, User, LoginResponse, LoginRequest, RegisterRequest } from "./types"; import { API_BASE_URL } from "./config"; @@ -8,10 +9,13 @@ export let authToken: string | null = null; // Helper function to get auth headers -export function getAuthHeaders(): Headers { - const headers: Headers = { - 'Content-Type': 'application/json', - }; +export function getAuthHeaders(json: boolean = true): Headers { + const headers: Headers = {}; + + if (json) { + headers["Content-Type"] = "application/json"; + } + if (authToken) { headers['Authorization'] = `Bearer ${authToken}`; } @@ -93,6 +97,7 @@ document.getElementById('login-form-element')!.addEventListener('submit', async currentUser = data.user; showChat(); loadMessages(); // Start loading messages + initializeProfile(); // Initialize profile after login } else { const data: ErrorResponse = await response.json(); showAlert('login-alerts', data.message || 'Неверное имя пользователя или пароль', 'danger'); diff --git a/frontend/src/chat.ts b/frontend/src/chat.ts index 1c71fc8..1d5f6d1 100644 --- a/frontend/src/chat.ts +++ b/frontend/src/chat.ts @@ -19,6 +19,22 @@ export function addMessage(message: Message, isAuthor: boolean) { const messageInner = document.createElement('div'); messageInner.classList.add('message-inner'); + // Add profile picture for received messages + if (!isAuthor) { + const profilePicDiv = document.createElement('div'); + profilePicDiv.classList.add('message-profile-pic'); + + const profileImg = document.createElement('img'); + profileImg.src = message.profile_picture || './src/images/default-avatar.png'; + profileImg.alt = message.username; + profileImg.onerror = () => { + profileImg.src = './src/images/default-avatar.png'; + }; + + profilePicDiv.appendChild(profileImg); + messageDiv.appendChild(profilePicDiv); + } + if (!isAuthor) { const usernameDiv = document.createElement('div'); usernameDiv.classList.add('message-username'); diff --git a/frontend/src/css/_chat.scss b/frontend/src/css/_chat.scss index fe4ecdf..0f143f3 100644 --- a/frontend/src/css/_chat.scss +++ b/frontend/src/css/_chat.scss @@ -151,6 +151,23 @@ max-width: 70%; position: relative; width: max-content; + display: flex; + align-items: flex-end; + gap: 8px; + + .message-profile-pic { + width: 32px; + height: 32px; + flex-shrink: 0; + margin-bottom: 4px; + + img { + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; + } + } .message-inner { padding: 0.8rem 1rem; @@ -174,6 +191,7 @@ &.sent { margin-left: auto; + flex-direction: row-reverse; .message-inner { background-color: $color-dark-primary-container; diff --git a/frontend/src/css/_profile.scss b/frontend/src/css/_profile.scss index deae3bb..76fe006 100644 --- a/frontend/src/css/_profile.scss +++ b/frontend/src/css/_profile.scss @@ -2,33 +2,117 @@ @use "common/material" as *; #profile-dialog { + .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; - .left { + .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; } } - .right { - display: flex; - flex-direction: column; - justify-content: center; + mdui-text-field { + flex: 1; + } + } - #profile-username { - font-size: larger; + #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; } } } +} + +// Cropper Dialog Styles +#cropper-dialog { + .cropper-dialog-content { + display: flex; + flex-direction: column; + gap: 16px; + min-width: 500px; + max-width: 600px; + } + + .cropper-header { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 16px; + border-bottom: 1px solid $color-dark-outline; + + h3 { + margin: 0; + color: $color-dark-on-surface; + } + } + + .cropper-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 400px; + background: $color-dark-surface-container; + border-radius: 8px; + overflow: hidden; + + #cropper-area { + width: 100%; + height: 100%; + min-height: 400px; + } + } + + .cropper-actions { + display: flex; + gap: 12px; + justify-content: flex-end; + padding-top: 16px; + border-top: 1px solid $color-dark-outline; + } } \ No newline at end of file diff --git a/frontend/src/images/pesel.svg b/frontend/src/images/pesel.svg deleted file mode 100644 index 7290bcc..0000000 --- a/frontend/src/images/pesel.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/frontend/src/leftpanel.ts b/frontend/src/leftpanel.ts index 9115422..e7fa548 100644 --- a/frontend/src/leftpanel.ts +++ b/frontend/src/leftpanel.ts @@ -1,4 +1,5 @@ import type { Dialog } from "mdui/components/dialog"; +import { loadProfilePicture } from "./profile/upload"; // сварачивание и разворачивание чата const but = document.getElementById('chat-recrol')!; @@ -29,6 +30,8 @@ const dialogClose = document.getElementById("profile-dialog-close")!; butprofile.addEventListener('click', () => { dialog.open = true; + // Load profile picture when dialog opens + loadProfilePicture(); }); dialogClose.addEventListener("click", () => { diff --git a/frontend/src/notification.ts b/frontend/src/notification.ts new file mode 100644 index 0000000..831e0a4 --- /dev/null +++ b/frontend/src/notification.ts @@ -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'); +} diff --git a/frontend/src/profile.ts b/frontend/src/profile.ts index 0377d9e..cbcf271 100644 --- a/frontend/src/profile.ts +++ b/frontend/src/profile.ts @@ -1,63 +1,31 @@ -// const fileInput = document.getElementById('fileInput') as HTMLInputElement; -// const preview = document.getElementById('preview') as HTMLImageElement; -// const preview1 = document.getElementById('preview1') as HTMLImageElement; -// const button = document.getElementById('uploadButton')!; +import type { Dialog } from "mdui/components/dialog"; +import { loadProfileData } from './profile/editor'; +import { loadProfilePicture, initializeProfileUpload } from "./profile/upload"; +import { initializeProfileEditor } from './profile/editor'; -// if (fileInput && preview && button) { -// // при клике по кнопке откроем диалог выбора файла -// button.addEventListener('click', () => { -// fileInput.click(); -// }); +// Handle profile form submission +const form = document.getElementById("profile-form")!; +const dialog = document.getElementById("profile-dialog") as Dialog; -// // при выборе файла — показываем его -// fileInput.addEventListener('change', () => { -// const file: File | null = fileInput.files ? fileInput.files[0] : null; -// if (file) { -// const reader: FileReader = new FileReader(); -// reader.onload = (e: ProgressEvent) => { -// 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); -// } -// }); -// } +form.addEventListener("submit", async (e) => { + e.preventDefault(); + + // TODO: Process form data if needed + // For now, just close the dialog + dialog.open = false; +}); -// const textDiv = document.getElementById('text-nik')!; -// const input = document.getElementById('nik') as HTMLInputElement; -// const button1 = document.getElementById('change-name')!; - -// button1.addEventListener('click', () => { -// if (input.style.display === 'none') { -// // Переводим в режим редактирования -// input.value = textDiv.textContent || ''; -// textDiv.style.display = 'none'; -// input.style.display = 'flex'; -// } else { -// // Сохраняем изменения -// 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'; -// } -// }); \ No newline at end of file +// Initialize profile functionality after login +export function initializeProfile(): void { + // Initialize profile modules + initializeProfileUpload(); + initializeProfileEditor(); + + // Load profile data + Promise.all([ + loadProfilePicture(), + loadProfileData() + ]).catch(error => { + console.error('Error initializing profile:', error); + }); +} \ No newline at end of file diff --git a/frontend/src/profile/README.md b/frontend/src/profile/README.md new file mode 100644 index 0000000..67aabce --- /dev/null +++ b/frontend/src/profile/README.md @@ -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)` - 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. diff --git a/frontend/src/profile/api.ts b/frontend/src/profile/api.ts new file mode 100644 index 0000000..7b5ba4e --- /dev/null +++ b/frontend/src/profile/api.ts @@ -0,0 +1,55 @@ +import { getAuthHeaders } from '../auth'; +import type { ProfileData, UploadResponse } from './types'; + +export async function loadProfile(): Promise { + try { + const response = await fetch('/api/user/profile', { + headers: getAuthHeaders() + }); + + if (response.ok) { + return await response.json(); + } + + return null; + } catch (error) { + console.error('Error loading profile:', error); + return null; + } +} + +export async function uploadProfilePicture(file: Blob): Promise { + try { + const formData = new FormData(); + formData.append('profile_picture', file, 'profile_picture.jpg'); + + const response = await fetch('/api/upload-profile-picture', { + method: 'POST', + body: formData, + headers: getAuthHeaders(false) + }); + + if (response.ok) { + return await response.json(); + } + return null; + } catch (error) { + console.error('Upload error:', error); + return null; + } +} + +export async function updateProfile(data: Partial): Promise { + try { + const response = await fetch('/api/user/profile', { + method: 'PUT', + headers: getAuthHeaders(), + body: JSON.stringify(data) + }); + + return response.ok; + } catch (error) { + console.error('Error updating profile:', error); + return false; + } +} diff --git a/frontend/src/profile/editor.ts b/frontend/src/profile/editor.ts new file mode 100644 index 0000000..40422ff --- /dev/null +++ b/frontend/src/profile/editor.ts @@ -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 { + 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(); +} diff --git a/frontend/src/profile/image-cropper.ts b/frontend/src/profile/image-cropper.ts new file mode 100644 index 0000000..45ac17c --- /dev/null +++ b/frontend/src/profile/image-cropper.ts @@ -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 { + 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); + } + } +} diff --git a/frontend/src/profile/types.ts b/frontend/src/profile/types.ts new file mode 100644 index 0000000..7a9aa34 --- /dev/null +++ b/frontend/src/profile/types.ts @@ -0,0 +1,9 @@ +export interface ProfileData { + profile_picture?: string; + nickname?: string; + description?: string; +} + +export interface UploadResponse { + profile_picture_url: string; +} \ No newline at end of file diff --git a/frontend/src/profile/upload.ts b/frontend/src/profile/upload.ts new file mode 100644 index 0000000..889e5c4 --- /dev/null +++ b/frontend/src/profile/upload.ts @@ -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 { + // 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 { + 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 { + 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(); +} diff --git a/frontend/src/settings.ts b/frontend/src/settings.ts index c575858..e792714 100644 --- a/frontend/src/settings.ts +++ b/frontend/src/settings.ts @@ -15,7 +15,6 @@ function initializeSettings() { // Create a mapping between list items and their corresponding panels const panelMapping = { - 'Профиль': 'profile-settings', 'Уведомления': 'notifications-settings', 'Внешний вид': 'appearance-settings', 'Безопасность': 'security-settings', diff --git a/frontend/src/types.ts b/frontend/src/types.ts index d341be8..acb61c5 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -4,6 +4,11 @@ export interface ErrorResponse { message: string; } +export interface Size2D { + x: number; + y: number; +} + // App types export interface Message { @@ -12,6 +17,7 @@ export interface Message { content: string; is_read: boolean; timestamp: string; + profile_picture?: string; } export interface Messages {