mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Implement profile
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
import { authToken, currentUser, getAuthHeaders } from "../auth/api";
|
||||
import { API_BASE_URL } from "../core/config";
|
||||
import { authToken, currentUser, getAuthHeaders } from "../auth/api.ts";
|
||||
import { API_BASE_URL } from "../core/config.ts";
|
||||
import type { Message, Messages, WebSocketMessage } from "../core/types";
|
||||
import { request } from "../websocket";
|
||||
import { addMessage } from "./chat";
|
||||
import { show as showContextMenu } from "./contextMenu";
|
||||
import { show as showProfileDialog } from "./profileDialog";
|
||||
import { request } from "../websocket.ts";
|
||||
import { addMessage } from "./chat.ts";
|
||||
import { show as showContextMenu } from "./contextMenu.ts";
|
||||
import { show as showProfileDialog } from "./profileDialog.ts";
|
||||
|
||||
const titleEl = document.getElementById("chat-name")!;
|
||||
const messages = document.getElementById("chat-messages")!;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { clearAlerts } from "./auth/auth";
|
||||
import { publicChatPanel } from "./chat/chat";
|
||||
import { id } from "./utils/utils";
|
||||
import { clearAlerts } from "./auth/auth.ts";
|
||||
import { publicChatPanel } from "./chat/chat.ts";
|
||||
import { id } from "./utils/utils.ts";
|
||||
|
||||
const loginForm = id("login-form");
|
||||
const registerForm = id("register-form");
|
||||
@@ -8,7 +8,7 @@
|
||||
import { Dialog } from "mdui/components/dialog";
|
||||
import { loadProfilePicture } from "./profile/upload";
|
||||
import { id } from "../utils/utils";
|
||||
import { publicChatPanel } from "../chat/chat";
|
||||
import { publicChatPanel } from "../chat/chat.ts";
|
||||
|
||||
// сварачивание и разворачивание чата
|
||||
const chatCollapseBtn = id('hide-chat')!;
|
||||
+17
-54
@@ -4,57 +4,20 @@ import type { Headers, User, WebSocketMessage } from "../core/types";
|
||||
import { clearAlerts } from "./auth";
|
||||
import { request } from "../websocket";
|
||||
|
||||
/**
|
||||
* Current authenticated user information
|
||||
* @type {User | null}
|
||||
*/
|
||||
export let currentUser: User | null = null;
|
||||
|
||||
/**
|
||||
* JWT authentication token
|
||||
* @type {string | null}
|
||||
*/
|
||||
export let authToken: string | null = null;
|
||||
|
||||
/**
|
||||
* Sets the current user to the values provided.
|
||||
* @param token The authentication JWT token.
|
||||
* @param user The current authenticated user.
|
||||
*/
|
||||
export function setUser(token: string, user: User) {
|
||||
authToken = token
|
||||
currentUser = user
|
||||
|
||||
try {
|
||||
const payload: WebSocketMessage = {
|
||||
type: "ping",
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: authToken
|
||||
},
|
||||
data: {}
|
||||
}
|
||||
|
||||
request(payload).then(() => {
|
||||
console.log("Ping succeeded")
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
export function getAuthHeaders(json: boolean = true): Headers {
|
||||
export function getAuthHeaders(token: string | null, json: boolean = true): Headers {
|
||||
const headers: Headers = {};
|
||||
|
||||
if (json) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
if (authToken) {
|
||||
headers['Authorization'] = `Bearer ${authToken}`;
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
@@ -71,18 +34,18 @@ export async function checkAuthStatus(): Promise<void> {
|
||||
/**
|
||||
* Logs out the current user and clears session data
|
||||
*/
|
||||
export async function logout(): Promise<void> {
|
||||
try {
|
||||
await fetch(`${API_BASE_URL}/logout`, {
|
||||
method: 'GET',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
}
|
||||
// export async function logout(): Promise<void> {
|
||||
// try {
|
||||
// await fetch(`${API_BASE_URL}/logout`, {
|
||||
// method: 'GET',
|
||||
// headers: getAuthHeaders()
|
||||
// });
|
||||
// } catch (error) {
|
||||
// console.error('Logout error:', error);
|
||||
// }
|
||||
|
||||
currentUser = null;
|
||||
authToken = null;
|
||||
// showLogin();
|
||||
clearAlerts();
|
||||
}
|
||||
// currentUser = null;
|
||||
// authToken = null;
|
||||
// // showLogin();
|
||||
// clearAlerts();
|
||||
// }
|
||||
@@ -8,8 +8,8 @@ import type { BackupBlob, UploadPublicKeyRequest } from "../core/types";
|
||||
let currentPublicKey: Uint8Array | null = null;
|
||||
let currentPrivateKey: Uint8Array | null = null;
|
||||
|
||||
async function fetchPublicKey(token?: string): Promise<Uint8Array | null> {
|
||||
const headers = token ? { 'Authorization': `Bearer ${token}` } : getAuthHeaders(true);
|
||||
async function fetchPublicKey(token: string): Promise<Uint8Array | null> {
|
||||
const headers = getAuthHeaders(token, true);
|
||||
const res = await fetch(`${API_BASE_URL}/crypto/public-key`, { method: "GET", headers });
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
@@ -17,12 +17,12 @@ async function fetchPublicKey(token?: string): Promise<Uint8Array | null> {
|
||||
return ub64(data.publicKey);
|
||||
}
|
||||
|
||||
async function uploadPublicKey(publicKey: Uint8Array, token?: string): Promise<void> {
|
||||
async function uploadPublicKey(publicKey: Uint8Array, token: string): Promise<void> {
|
||||
const payload: UploadPublicKeyRequest = {
|
||||
publicKey: b64(publicKey)
|
||||
}
|
||||
|
||||
const headers = token ? { 'Authorization': `Bearer ${token}` } : getAuthHeaders(true);
|
||||
const headers = getAuthHeaders(token, true);
|
||||
await fetch(`${API_BASE_URL}/crypto/public-key`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
@@ -30,8 +30,8 @@ async function uploadPublicKey(publicKey: Uint8Array, token?: string): Promise<v
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchBackupBlob(token?: string): Promise<string | null> {
|
||||
const headers = token ? { 'Authorization': `Bearer ${token}` } : getAuthHeaders(true);
|
||||
async function fetchBackupBlob(token: string): Promise<string | null> {
|
||||
const headers = getAuthHeaders(token, true);
|
||||
const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
|
||||
method: "GET",
|
||||
headers
|
||||
@@ -44,10 +44,10 @@ async function fetchBackupBlob(token?: string): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadBackupBlob(blobJson: string, token?: string): Promise<void> {
|
||||
async function uploadBackupBlob(blobJson: string, token: string): Promise<void> {
|
||||
const payload: BackupBlob = { blob: blobJson }
|
||||
|
||||
const headers = token ? { 'Authorization': `Bearer ${token}` } : getAuthHeaders(true);
|
||||
const headers = getAuthHeaders(token, true);
|
||||
await fetch(`${API_BASE_URL}/crypto/backup`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
@@ -65,7 +65,7 @@ export function getCurrentKeys(): UserKeyPairMemory | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function ensureKeysOnLogin(password: string, token?: string): Promise<UserKeyPairMemory> {
|
||||
export async function ensureKeysOnLogin(password: string, token: string): Promise<UserKeyPairMemory> {
|
||||
// Try to restore from backup
|
||||
const blobJson = await fetchBackupBlob(token);
|
||||
if (blobJson) {
|
||||
|
||||
@@ -5,12 +5,6 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import { showLogin } from "../navigation";
|
||||
import { PRODUCT_NAME } from "./config";
|
||||
|
||||
showLogin();
|
||||
|
||||
document.querySelectorAll(".product-name").forEach(el => {
|
||||
el.textContent = PRODUCT_NAME;
|
||||
});
|
||||
document.title = PRODUCT_NAME;
|
||||
@@ -0,0 +1,92 @@
|
||||
import { getAuthHeaders } from "../../auth/api";
|
||||
import { API_BASE_URL } from "../../core/config";
|
||||
|
||||
export interface ProfileData {
|
||||
profile_picture?: string;
|
||||
nickname?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UploadResponse {
|
||||
profile_picture_url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads user profile data from the server
|
||||
*/
|
||||
export async function loadProfile(token: string): Promise<ProfileData | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/profile`, {
|
||||
headers: getAuthHeaders(token)
|
||||
});
|
||||
|
||||
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
|
||||
*/
|
||||
export async function uploadProfilePicture(token: string, file: Blob): Promise<UploadResponse | null> {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('profile_picture', file, 'profile_picture.jpg');
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/upload-profile-picture`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: getAuthHeaders(token, false)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates user profile information
|
||||
*/
|
||||
export async function updateProfile(token: string, data: Partial<ProfileData>): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/profile`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(token),
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error('Error updating profile:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates user bio
|
||||
*/
|
||||
export async function updateBio(token: string, bio: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/bio`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(token),
|
||||
body: JSON.stringify({ bio })
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error('Error updating bio:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,32 @@
|
||||
import { PRODUCT_NAME } from "../../../core/config";
|
||||
import { useDialog } from "../../contexts/DialogContext";
|
||||
import { useProfile } from "../../hooks/useProfile";
|
||||
import defaultAvatar from "../../../resources/images/default-avatar.png";
|
||||
|
||||
export function ChatHeader() {
|
||||
const { openProfile } = useDialog();
|
||||
const { profileData } = useProfile();
|
||||
|
||||
const handleProfileClick = () => {
|
||||
openProfile();
|
||||
};
|
||||
|
||||
const profilePictureUrl = profileData?.profile_picture || defaultAvatar;
|
||||
|
||||
return (
|
||||
<header className="chat-header-left">
|
||||
<div className="product-name">{PRODUCT_NAME}</div>
|
||||
<div className="profile">
|
||||
<a href="#" id="profile-open" onClick={handleProfileClick}>
|
||||
<img src="./src/resources/images/default-avatar.png" alt="" id="preview1" />
|
||||
<img
|
||||
src={profilePictureUrl}
|
||||
alt=""
|
||||
id="preview1"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.src = defaultAvatar;
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface ImageCropperProps {
|
||||
onCrop: (croppedImageData: string) => void;
|
||||
onCancel: () => void;
|
||||
imageFile: File | null;
|
||||
}
|
||||
|
||||
export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const [cropArea, setCropArea] = useState({ x: 0, y: 0, width: 200, height: 200 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageFile) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
if (imageRef.current) {
|
||||
imageRef.current.src = e.target?.result as string;
|
||||
imageRef.current.onload = () => {
|
||||
setIsLoaded(true);
|
||||
// Initialize crop area to center of image
|
||||
if (imageRef.current) {
|
||||
const img = imageRef.current;
|
||||
const size = Math.min(img.naturalWidth, img.naturalHeight) * 0.8;
|
||||
setCropArea({
|
||||
x: (img.naturalWidth - size) / 2,
|
||||
y: (img.naturalHeight - size) / 2,
|
||||
width: size,
|
||||
height: size
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(imageFile);
|
||||
}, [imageFile]);
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (!isLoaded) return;
|
||||
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
// Check if click is within crop area
|
||||
if (x >= cropArea.x && x <= cropArea.x + cropArea.width &&
|
||||
y >= cropArea.y && y <= cropArea.y + cropArea.height) {
|
||||
setIsDragging(true);
|
||||
setDragStart({ x: x - cropArea.x, y: y - cropArea.y });
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (!isDragging || !isLoaded) return;
|
||||
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect || !imageRef.current) return;
|
||||
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
const newX = Math.max(0, Math.min(x - dragStart.x, imageRef.current.naturalWidth - cropArea.width));
|
||||
const newY = Math.max(0, Math.min(y - dragStart.y, imageRef.current.naturalHeight - cropArea.height));
|
||||
|
||||
setCropArea(prev => ({ ...prev, x: newX, y: newY }));
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleCrop = () => {
|
||||
if (!canvasRef.current || !imageRef.current || !isLoaded) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Set canvas size to crop area
|
||||
canvas.width = cropArea.width;
|
||||
canvas.height = cropArea.height;
|
||||
|
||||
// Draw cropped portion
|
||||
ctx.drawImage(
|
||||
imageRef.current,
|
||||
cropArea.x, cropArea.y, cropArea.width, cropArea.height,
|
||||
0, 0, cropArea.width, cropArea.height
|
||||
);
|
||||
|
||||
// Convert to data URL
|
||||
const croppedImageData = canvas.toDataURL('image/jpeg', 0.9);
|
||||
onCrop(croppedImageData);
|
||||
};
|
||||
|
||||
const drawCropArea = () => {
|
||||
if (!canvasRef.current || !imageRef.current || !isLoaded) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Draw image
|
||||
ctx.drawImage(imageRef.current, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Draw crop overlay
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Clear crop area
|
||||
ctx.globalCompositeOperation = 'destination-out';
|
||||
ctx.fillRect(cropArea.x, cropArea.y, cropArea.width, cropArea.height);
|
||||
|
||||
// Draw crop border
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(cropArea.x, cropArea.y, cropArea.width, cropArea.height);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
drawCropArea();
|
||||
}, [cropArea, isLoaded]);
|
||||
|
||||
if (!imageFile) return null;
|
||||
|
||||
return (
|
||||
<div className="cropper-container">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={400}
|
||||
height={400}
|
||||
style={{
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
border: '1px solid #ccc',
|
||||
maxWidth: '100%',
|
||||
height: 'auto'
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
/>
|
||||
<img
|
||||
ref={imageRef}
|
||||
style={{ display: 'none' }}
|
||||
alt="Crop source"
|
||||
/>
|
||||
<div className="cropper-actions">
|
||||
<mdui-button onClick={handleCrop} disabled={!isLoaded}>
|
||||
Обрезать
|
||||
</mdui-button>
|
||||
<mdui-button variant="outlined" onClick={onCancel}>
|
||||
Отмена
|
||||
</mdui-button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +1,179 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useState, useEffect, useRef, type FormEvent } from "react";
|
||||
import defaultAvatar from "../../../resources/images/default-avatar.png";
|
||||
import type { TextField } from "mdui/components/text-field";
|
||||
import type { DialogProps } from "../../../core/types";
|
||||
import { MaterialDialog } from "../Dialog";
|
||||
import { useProfile } from "../../hooks/useProfile";
|
||||
import { ImageCropper } from "./ImageCropper";
|
||||
|
||||
export function ProfileDialog({ isOpen, onOpenChange }: DialogProps) {
|
||||
const [username, setUsername] = useState("user123");
|
||||
const { profileData, isLoading, isUpdating, updateProfileData, uploadProfilePictureData } = useProfile();
|
||||
const [username, setUsername] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [selectedImage, setSelectedImage] = useState<File | null>(null);
|
||||
const [showCropper, setShowCropper] = useState(false);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
// Update form fields when profile data changes
|
||||
useEffect(() => {
|
||||
if (profileData) {
|
||||
setUsername(profileData.nickname || "");
|
||||
setDescription(profileData.description || "");
|
||||
}
|
||||
}, [profileData]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
// TODO: Implement profile update logic
|
||||
console.log("Profile update:", { username, description });
|
||||
onOpenChange(false);
|
||||
|
||||
const success = await updateProfileData({
|
||||
nickname: username.trim() || undefined,
|
||||
description: description.trim() || undefined
|
||||
});
|
||||
|
||||
if (success) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<MaterialDialog id="profile-dialog" close-on-overlay-click close-on-esc open={isOpen} onOpenChange={onOpenChange}>
|
||||
<div className="content">
|
||||
<div className="header-top">
|
||||
<div className="profile-picture-container">
|
||||
<img id="profile-picture" src={defaultAvatar} alt="Ваше фото" />
|
||||
<mdui-button-icon icon="camera_alt--filled" id="upload-pfp-btn" className="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={username}
|
||||
onChange={(e: FormEvent<HTMLElement & TextField>) => setUsername((e.target as TextField).value)}
|
||||
autocomplete="username">
|
||||
</mdui-text-field>
|
||||
</div>
|
||||
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
setSelectedImage(file);
|
||||
setShowCropper(true);
|
||||
}
|
||||
};
|
||||
|
||||
<form id="profile-form" onSubmit={handleSubmit}>
|
||||
<mdui-text-field
|
||||
id="description-field"
|
||||
label="О себе"
|
||||
variant="outlined"
|
||||
value={description}
|
||||
onChange={(e: FormEvent<HTMLElement & TextField>) => setDescription((e.target as TextField).value)}
|
||||
placeholder="Расскажите о себе..."
|
||||
autocomplete="none">
|
||||
</mdui-text-field>
|
||||
<div className="dialog-actions">
|
||||
<mdui-button type="submit" id="profile-submit">Сохранить изменения</mdui-button>
|
||||
<mdui-button id="profile-dialog-close" variant="outlined" onClick={() => onOpenChange(false)}>Закрыть</mdui-button>
|
||||
const handleCropComplete = async (croppedImageData: string) => {
|
||||
try {
|
||||
// Convert data URL to blob
|
||||
const response = await fetch(croppedImageData);
|
||||
const blob = await response.blob();
|
||||
|
||||
const success = await uploadProfilePictureData(blob);
|
||||
if (success) {
|
||||
setShowCropper(false);
|
||||
setSelectedImage(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing cropped image:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCropCancel = () => {
|
||||
setShowCropper(false);
|
||||
setSelectedImage(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const profilePictureUrl = profileData?.profile_picture || defaultAvatar;
|
||||
|
||||
return (
|
||||
<>
|
||||
<MaterialDialog id="profile-dialog" close-on-overlay-click close-on-esc open={isOpen} onOpenChange={onOpenChange}>
|
||||
<div className="content">
|
||||
<div className="header-top">
|
||||
<div className="profile-picture-container">
|
||||
<img
|
||||
id="profile-picture"
|
||||
src={profilePictureUrl}
|
||||
alt="Ваше фото"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.src = defaultAvatar;
|
||||
}}
|
||||
/>
|
||||
<mdui-button-icon
|
||||
icon="camera_alt--filled"
|
||||
id="upload-pfp-btn"
|
||||
className="upload-overlay"
|
||||
variant="filled"
|
||||
onClick={handleUploadClick}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
id="pfp-file-input"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleImageSelect}
|
||||
/>
|
||||
</div>
|
||||
<mdui-text-field
|
||||
id="username-field"
|
||||
label="Имя пользователя"
|
||||
variant="outlined"
|
||||
value={username}
|
||||
onChange={(e: FormEvent<HTMLElement & TextField>) => setUsername((e.target as TextField).value)}
|
||||
autocomplete="username"
|
||||
disabled={isLoading || isUpdating}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</MaterialDialog>
|
||||
|
||||
<form id="profile-form" onSubmit={handleSubmit}>
|
||||
<mdui-text-field
|
||||
id="description-field"
|
||||
label="О себе"
|
||||
variant="outlined"
|
||||
value={description}
|
||||
onChange={(e: FormEvent<HTMLElement & TextField>) => setDescription((e.target as TextField).value)}
|
||||
placeholder="Расскажите о себе..."
|
||||
autocomplete="none"
|
||||
disabled={isLoading || isUpdating}
|
||||
/>
|
||||
<div className="dialog-actions">
|
||||
<mdui-button
|
||||
type="submit"
|
||||
id="profile-submit"
|
||||
disabled={isLoading || isUpdating}
|
||||
>
|
||||
{isUpdating ? "Сохранение..." : "Сохранить изменения"}
|
||||
</mdui-button>
|
||||
<mdui-button
|
||||
id="profile-dialog-close"
|
||||
variant="outlined"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
Закрыть
|
||||
</mdui-button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</MaterialDialog>
|
||||
|
||||
{/* Image Cropper Dialog */}
|
||||
<MaterialDialog
|
||||
id="cropper-dialog"
|
||||
close-on-overlay-click
|
||||
close-on-esc
|
||||
open={showCropper}
|
||||
onOpenChange={setShowCropper}
|
||||
>
|
||||
<div className="cropper-dialog-content">
|
||||
<div className="cropper-header">
|
||||
<h3>Обрезать фото профиля</h3>
|
||||
<mdui-button-icon icon="close" onClick={handleCropCancel} />
|
||||
</div>
|
||||
<div className="cropper-container">
|
||||
<ImageCropper
|
||||
imageFile={selectedImage}
|
||||
onCrop={handleCropComplete}
|
||||
onCancel={handleCropCancel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</MaterialDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export function useChat() {
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/get_messages`, {
|
||||
headers: getAuthHeaders()
|
||||
headers: getAuthHeaders(user.authToken)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useAppState } from "../state";
|
||||
import { loadProfile, updateProfile, uploadProfilePicture, type ProfileData } from "../api/profileApi";
|
||||
import { showSuccess, showError } from "../../utils/notification";
|
||||
|
||||
export function useProfile() {
|
||||
const { user } = useAppState();
|
||||
const [profileData, setProfileData] = useState<ProfileData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
// Load profile data
|
||||
const loadProfileData = useCallback(async () => {
|
||||
if (!user.authToken) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await loadProfile(user.authToken);
|
||||
if (data) {
|
||||
setProfileData(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading profile:', error);
|
||||
showError('Ошибка при загрузке профиля');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [user.authToken]);
|
||||
|
||||
// Update profile
|
||||
const updateProfileData = useCallback(async (data: Partial<ProfileData>) => {
|
||||
if (!user.authToken) return false;
|
||||
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
const success = await updateProfile(user.authToken, data);
|
||||
if (success) {
|
||||
// Reload profile data to get updated information
|
||||
await loadProfileData();
|
||||
showSuccess('Профиль обновлен!');
|
||||
return true;
|
||||
} else {
|
||||
showError('Ошибка при обновлении профиля');
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating profile:', error);
|
||||
showError('Ошибка при обновлении профиля');
|
||||
return false;
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
}, [user.authToken, loadProfileData]);
|
||||
|
||||
// Upload profile picture
|
||||
const uploadProfilePictureData = useCallback(async (file: Blob) => {
|
||||
if (!user.authToken) return false;
|
||||
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
const result = await uploadProfilePicture(user.authToken, file);
|
||||
if (result) {
|
||||
// Update profile data with new picture URL
|
||||
setProfileData(prev => prev ? {
|
||||
...prev,
|
||||
profile_picture: result.profile_picture_url
|
||||
} : null);
|
||||
showSuccess('Фото профиля обновлено!');
|
||||
return true;
|
||||
} else {
|
||||
showError('Ошибка при загрузке фото');
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error uploading profile picture:', error);
|
||||
showError('Ошибка при загрузке фото');
|
||||
return false;
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
}, [user.authToken]);
|
||||
|
||||
// Load profile data when user is authenticated
|
||||
useEffect(() => {
|
||||
if (user.authToken) {
|
||||
loadProfileData();
|
||||
}
|
||||
}, [user.authToken, loadProfileData]);
|
||||
|
||||
return {
|
||||
profileData,
|
||||
isLoading,
|
||||
isUpdating,
|
||||
loadProfileData,
|
||||
updateProfileData,
|
||||
uploadProfilePictureData
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ChatInterface } from "../components/chat/ChatInterface";
|
||||
import { ProfileDialog } from "../components/profile/ProfileDialog";
|
||||
import { CropperDialog } from "../components/profile/CropperDialog";
|
||||
import { SettingsDialog } from "../components/settings/SettingsDialog";
|
||||
import { MessageContextMenu } from "../components/chat/MessageContextMenu";
|
||||
@@ -11,12 +10,12 @@ export default function ChatScreen() {
|
||||
return (
|
||||
<>
|
||||
<ChatInterface />
|
||||
|
||||
<CropperDialog />
|
||||
<SettingsDialog />
|
||||
<MessageContextMenu />
|
||||
<EditMessageDialog />
|
||||
<ReplyMessageDialog />
|
||||
<UserProfileDialog isOpen={false} onOpenChange={() => {}} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { create } from "zustand";
|
||||
import type { Message, User, UserProfile } from "../core/types";
|
||||
import type { Message, User, UserProfile, WebSocketMessage } from "../core/types";
|
||||
import { request } from "../websocket";
|
||||
|
||||
type Page = "login" | "register" | "chat"
|
||||
|
||||
@@ -113,13 +114,30 @@ export const useAppState = create<AppState>((set, get) => ({
|
||||
currentUser: null,
|
||||
authToken: null
|
||||
},
|
||||
setUser: (token: string, user: User) => set((state) => ({
|
||||
user: {
|
||||
currentUser: user,
|
||||
authToken: token
|
||||
}
|
||||
})),
|
||||
logout: () => set((state) => ({
|
||||
setUser: (token: string, user: User) => {
|
||||
set(() => ({
|
||||
user: {
|
||||
currentUser: user,
|
||||
authToken: token
|
||||
}
|
||||
}));
|
||||
|
||||
try {
|
||||
const payload: WebSocketMessage = {
|
||||
type: "ping",
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: token
|
||||
},
|
||||
data: {}
|
||||
}
|
||||
|
||||
request(payload).then(() => {
|
||||
console.log("Ping succeeded")
|
||||
})
|
||||
} catch {}
|
||||
},
|
||||
logout: () => set(() => ({
|
||||
user: {
|
||||
currentUser: null,
|
||||
authToken: null
|
||||
|
||||
Reference in New Issue
Block a user