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 { 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
@@ -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();
|
||||||
}
|
// }
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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 { 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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 { 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={() => {}} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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": ["**/__*/**", "__*"]
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user