Implement profile

This commit is contained in:
2025-08-30 23:01:59 +03:00
Unverified
parent 3592262838
commit 9f2f6f86f9
26 changed files with 602 additions and 132 deletions
@@ -1,10 +1,10 @@
import { authToken, currentUser, getAuthHeaders } from "../auth/api"; import { authToken, currentUser, getAuthHeaders } from "../auth/api.ts";
import { API_BASE_URL } from "../core/config"; import { API_BASE_URL } from "../core/config.ts";
import type { Message, Messages, WebSocketMessage } from "../core/types"; import type { Message, Messages, WebSocketMessage } from "../core/types";
import { request } from "../websocket"; import { request } from "../websocket.ts";
import { addMessage } from "./chat"; import { addMessage } from "./chat.ts";
import { show as showContextMenu } from "./contextMenu"; import { show as showContextMenu } from "./contextMenu.ts";
import { show as showProfileDialog } from "./profileDialog"; import { show as showProfileDialog } from "./profileDialog.ts";
const titleEl = document.getElementById("chat-name")!; const titleEl = document.getElementById("chat-name")!;
const messages = document.getElementById("chat-messages")!; const messages = document.getElementById("chat-messages")!;
@@ -1,6 +1,6 @@
import { clearAlerts } from "./auth/auth"; import { clearAlerts } from "./auth/auth.ts";
import { publicChatPanel } from "./chat/chat"; import { publicChatPanel } from "./chat/chat.ts";
import { id } from "./utils/utils"; import { id } from "./utils/utils.ts";
const loginForm = id("login-form"); const loginForm = id("login-form");
const registerForm = id("register-form"); const registerForm = id("register-form");
@@ -8,7 +8,7 @@
import { Dialog } from "mdui/components/dialog"; import { Dialog } from "mdui/components/dialog";
import { loadProfilePicture } from "./profile/upload"; import { loadProfilePicture } from "./profile/upload";
import { id } from "../utils/utils"; import { id } from "../utils/utils";
import { publicChatPanel } from "../chat/chat"; import { publicChatPanel } from "../chat/chat.ts";
// сварачивание и разворачивание чата // сварачивание и разворачивание чата
const chatCollapseBtn = id('hide-chat')!; const chatCollapseBtn = id('hide-chat')!;
+17 -54
View File
@@ -4,57 +4,20 @@ import type { Headers, User, WebSocketMessage } from "../core/types";
import { clearAlerts } from "./auth"; import { clearAlerts } from "./auth";
import { request } from "../websocket"; 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 * Generates authentication headers for API requests
* @param {boolean} json - Whether to include JSON content type header * @param {boolean} json - Whether to include JSON content type header
* @returns {Headers} Headers object with authentication and content type * @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 = {}; const headers: Headers = {};
if (json) { if (json) {
headers["Content-Type"] = "application/json"; headers["Content-Type"] = "application/json";
} }
if (authToken) { if (token) {
headers['Authorization'] = `Bearer ${authToken}`; headers['Authorization'] = `Bearer ${token}`;
} }
return headers; return headers;
} }
@@ -71,18 +34,18 @@ export async function checkAuthStatus(): Promise<void> {
/** /**
* Logs out the current user and clears session data * Logs out the current user and clears session data
*/ */
export async function logout(): Promise<void> { // export async function logout(): Promise<void> {
try { // try {
await fetch(`${API_BASE_URL}/logout`, { // await fetch(`${API_BASE_URL}/logout`, {
method: 'GET', // method: 'GET',
headers: getAuthHeaders() // headers: getAuthHeaders()
}); // });
} catch (error) { // } catch (error) {
console.error('Logout error:', error); // console.error('Logout error:', error);
} // }
currentUser = null; // currentUser = null;
authToken = null; // authToken = null;
// showLogin(); // // showLogin();
clearAlerts(); // clearAlerts();
} // }
+9 -9
View File
@@ -8,8 +8,8 @@ import type { BackupBlob, UploadPublicKeyRequest } from "../core/types";
let currentPublicKey: Uint8Array | null = null; let currentPublicKey: Uint8Array | null = null;
let currentPrivateKey: Uint8Array | null = null; let currentPrivateKey: Uint8Array | null = null;
async function fetchPublicKey(token?: string): Promise<Uint8Array | null> { async function fetchPublicKey(token: string): Promise<Uint8Array | null> {
const headers = token ? { 'Authorization': `Bearer ${token}` } : getAuthHeaders(true); const headers = getAuthHeaders(token, true);
const res = await fetch(`${API_BASE_URL}/crypto/public-key`, { method: "GET", headers }); const res = await fetch(`${API_BASE_URL}/crypto/public-key`, { method: "GET", headers });
if (!res.ok) return null; if (!res.ok) return null;
const data = await res.json(); const data = await res.json();
@@ -17,12 +17,12 @@ async function fetchPublicKey(token?: string): Promise<Uint8Array | null> {
return ub64(data.publicKey); 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 = { const payload: UploadPublicKeyRequest = {
publicKey: b64(publicKey) publicKey: b64(publicKey)
} }
const headers = token ? { 'Authorization': `Bearer ${token}` } : getAuthHeaders(true); const headers = getAuthHeaders(token, true);
await fetch(`${API_BASE_URL}/crypto/public-key`, { await fetch(`${API_BASE_URL}/crypto/public-key`, {
method: "POST", method: "POST",
headers, headers,
@@ -30,8 +30,8 @@ async function uploadPublicKey(publicKey: Uint8Array, token?: string): Promise<v
}); });
} }
async function fetchBackupBlob(token?: string): Promise<string | null> { async function fetchBackupBlob(token: string): Promise<string | null> {
const headers = token ? { 'Authorization': `Bearer ${token}` } : getAuthHeaders(true); const headers = getAuthHeaders(token, true);
const res = await fetch(`${API_BASE_URL}/crypto/backup`, { const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
method: "GET", method: "GET",
headers 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 payload: BackupBlob = { blob: blobJson }
const headers = token ? { 'Authorization': `Bearer ${token}` } : getAuthHeaders(true); const headers = getAuthHeaders(token, true);
await fetch(`${API_BASE_URL}/crypto/backup`, { await fetch(`${API_BASE_URL}/crypto/backup`, {
method: "POST", method: "POST",
headers, headers,
@@ -65,7 +65,7 @@ export function getCurrentKeys(): UserKeyPairMemory | null {
return 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 // Try to restore from backup
const blobJson = await fetchBackupBlob(token); const blobJson = await fetchBackupBlob(token);
if (blobJson) { if (blobJson) {
-6
View File
@@ -5,12 +5,6 @@
* @version 1.0.0 * @version 1.0.0
*/ */
import { showLogin } from "../navigation";
import { PRODUCT_NAME } from "./config"; import { PRODUCT_NAME } from "./config";
showLogin();
document.querySelectorAll(".product-name").forEach(el => {
el.textContent = PRODUCT_NAME;
});
document.title = PRODUCT_NAME; document.title = PRODUCT_NAME;
+92
View File
@@ -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;
}
}
+14 -1
View File
@@ -1,19 +1,32 @@
import { PRODUCT_NAME } from "../../../core/config"; import { PRODUCT_NAME } from "../../../core/config";
import { useDialog } from "../../contexts/DialogContext"; import { useDialog } from "../../contexts/DialogContext";
import { useProfile } from "../../hooks/useProfile";
import defaultAvatar from "../../../resources/images/default-avatar.png";
export function ChatHeader() { export function ChatHeader() {
const { openProfile } = useDialog(); const { openProfile } = useDialog();
const { profileData } = useProfile();
const handleProfileClick = () => { const handleProfileClick = () => {
openProfile(); openProfile();
}; };
const profilePictureUrl = profileData?.profile_picture || defaultAvatar;
return ( return (
<header className="chat-header-left"> <header className="chat-header-left">
<div className="product-name">{PRODUCT_NAME}</div> <div className="product-name">{PRODUCT_NAME}</div>
<div className="profile"> <div className="profile">
<a href="#" id="profile-open" onClick={handleProfileClick}> <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> </a>
</div> </div>
</header> </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,28 +1,113 @@
import { useState, type FormEvent } from "react"; import { useState, useEffect, useRef, type FormEvent } from "react";
import defaultAvatar from "../../../resources/images/default-avatar.png"; import defaultAvatar from "../../../resources/images/default-avatar.png";
import type { TextField } from "mdui/components/text-field"; import type { TextField } from "mdui/components/text-field";
import type { DialogProps } from "../../../core/types"; import type { DialogProps } from "../../../core/types";
import { MaterialDialog } from "../Dialog"; import { MaterialDialog } from "../Dialog";
import { useProfile } from "../../hooks/useProfile";
import { ImageCropper } from "./ImageCropper";
export function ProfileDialog({ isOpen, onOpenChange }: DialogProps) { 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 [description, setDescription] = useState("");
const [selectedImage, setSelectedImage] = useState<File | null>(null);
const [showCropper, setShowCropper] = useState(false);
const handleSubmit = (e: React.FormEvent) => { const fileInputRef = useRef<HTMLInputElement>(null);
// 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(); e.preventDefault();
// TODO: Implement profile update logic
console.log("Profile update:", { username, description }); const success = await updateProfileData({
nickname: username.trim() || undefined,
description: description.trim() || undefined
});
if (success) {
onOpenChange(false); onOpenChange(false);
}
}; };
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file && file.type.startsWith('image/')) {
setSelectedImage(file);
setShowCropper(true);
}
};
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 ( return (
<>
<MaterialDialog id="profile-dialog" close-on-overlay-click close-on-esc open={isOpen} onOpenChange={onOpenChange}> <MaterialDialog id="profile-dialog" close-on-overlay-click close-on-esc open={isOpen} onOpenChange={onOpenChange}>
<div className="content"> <div className="content">
<div className="header-top"> <div className="header-top">
<div className="profile-picture-container"> <div className="profile-picture-container">
<img id="profile-picture" src={defaultAvatar} alt="Ваше фото" /> <img
<mdui-button-icon icon="camera_alt--filled" id="upload-pfp-btn" className="upload-overlay" variant="filled"></mdui-button-icon> id="profile-picture"
<input type="file" id="pfp-file-input" accept="image/*" style={{ display: "none" }} /> 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> </div>
<mdui-text-field <mdui-text-field
id="username-field" id="username-field"
@@ -30,8 +115,9 @@ export function ProfileDialog({ isOpen, onOpenChange }: DialogProps) {
variant="outlined" variant="outlined"
value={username} value={username}
onChange={(e: FormEvent<HTMLElement & TextField>) => setUsername((e.target as TextField).value)} onChange={(e: FormEvent<HTMLElement & TextField>) => setUsername((e.target as TextField).value)}
autocomplete="username"> autocomplete="username"
</mdui-text-field> disabled={isLoading || isUpdating}
/>
</div> </div>
<form id="profile-form" onSubmit={handleSubmit}> <form id="profile-form" onSubmit={handleSubmit}>
@@ -42,14 +128,52 @@ export function ProfileDialog({ isOpen, onOpenChange }: DialogProps) {
value={description} value={description}
onChange={(e: FormEvent<HTMLElement & TextField>) => setDescription((e.target as TextField).value)} onChange={(e: FormEvent<HTMLElement & TextField>) => setDescription((e.target as TextField).value)}
placeholder="Расскажите о себе..." placeholder="Расскажите о себе..."
autocomplete="none"> autocomplete="none"
</mdui-text-field> disabled={isLoading || isUpdating}
/>
<div className="dialog-actions"> <div className="dialog-actions">
<mdui-button type="submit" id="profile-submit">Сохранить изменения</mdui-button> <mdui-button
<mdui-button id="profile-dialog-close" variant="outlined" onClick={() => onOpenChange(false)}>Закрыть</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> </div>
</form> </form>
</div> </div>
</MaterialDialog> </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>
</>
); );
} }
+1 -1
View File
@@ -27,7 +27,7 @@ export function useChat() {
try { try {
const response = await fetch(`${API_BASE_URL}/get_messages`, { const response = await fetch(`${API_BASE_URL}/get_messages`, {
headers: getAuthHeaders() headers: getAuthHeaders(user.authToken)
}); });
if (response.ok) { if (response.ok) {
+98
View File
@@ -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 -2
View File
@@ -1,5 +1,4 @@
import { ChatInterface } from "../components/chat/ChatInterface"; import { ChatInterface } from "../components/chat/ChatInterface";
import { ProfileDialog } from "../components/profile/ProfileDialog";
import { CropperDialog } from "../components/profile/CropperDialog"; import { CropperDialog } from "../components/profile/CropperDialog";
import { SettingsDialog } from "../components/settings/SettingsDialog"; import { SettingsDialog } from "../components/settings/SettingsDialog";
import { MessageContextMenu } from "../components/chat/MessageContextMenu"; import { MessageContextMenu } from "../components/chat/MessageContextMenu";
@@ -11,12 +10,12 @@ export default function ChatScreen() {
return ( return (
<> <>
<ChatInterface /> <ChatInterface />
<CropperDialog /> <CropperDialog />
<SettingsDialog /> <SettingsDialog />
<MessageContextMenu /> <MessageContextMenu />
<EditMessageDialog /> <EditMessageDialog />
<ReplyMessageDialog /> <ReplyMessageDialog />
<UserProfileDialog isOpen={false} onOpenChange={() => {}} />
</> </>
); );
} }
+22 -4
View File
@@ -1,5 +1,6 @@
import { create } from "zustand"; 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" type Page = "login" | "register" | "chat"
@@ -113,13 +114,30 @@ export const useAppState = create<AppState>((set, get) => ({
currentUser: null, currentUser: null,
authToken: null authToken: null
}, },
setUser: (token: string, user: User) => set((state) => ({ setUser: (token: string, user: User) => {
set(() => ({
user: { user: {
currentUser: user, currentUser: user,
authToken: token authToken: token
} }
})), }));
logout: () => set((state) => ({
try {
const payload: WebSocketMessage = {
type: "ping",
credentials: {
scheme: "Bearer",
credentials: token
},
data: {}
}
request(payload).then(() => {
console.log("Ping succeeded")
})
} catch {}
},
logout: () => set(() => ({
user: { user: {
currentUser: null, currentUser: null,
authToken: null authToken: null
+2 -1
View File
@@ -23,5 +23,6 @@
"jsx": "react-jsx", "jsx": "react-jsx",
"jsxImportSource": "react" "jsxImportSource": "react"
}, },
"include": ["src", "electron.d.ts"] "include": ["src", "electron.d.ts"],
"exclude": ["**/__*/**", "__*"]
} }