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 { 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
View File
@@ -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();
// }
+9 -9
View File
@@ -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) {
-6
View File
@@ -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;
+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 { 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,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 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 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();
// 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);
}
};
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 (
<>
<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" }} />
<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"
@@ -30,8 +115,9 @@ export function ProfileDialog({ isOpen, onOpenChange }: DialogProps) {
variant="outlined"
value={username}
onChange={(e: FormEvent<HTMLElement & TextField>) => setUsername((e.target as TextField).value)}
autocomplete="username">
</mdui-text-field>
autocomplete="username"
disabled={isLoading || isUpdating}
/>
</div>
<form id="profile-form" onSubmit={handleSubmit}>
@@ -42,14 +128,52 @@ export function ProfileDialog({ isOpen, onOpenChange }: DialogProps) {
value={description}
onChange={(e: FormEvent<HTMLElement & TextField>) => setDescription((e.target as TextField).value)}
placeholder="Расскажите о себе..."
autocomplete="none">
</mdui-text-field>
autocomplete="none"
disabled={isLoading || isUpdating}
/>
<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>
<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>
</>
);
}
+1 -1
View File
@@ -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) {
+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 { 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={() => {}} />
</>
);
}
+22 -4
View File
@@ -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) => ({
setUser: (token: string, user: User) => {
set(() => ({
user: {
currentUser: user,
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: {
currentUser: null,
authToken: null
+2 -1
View File
@@ -23,5 +23,6 @@
"jsx": "react-jsx",
"jsxImportSource": "react"
},
"include": ["src", "electron.d.ts"]
"include": ["src", "electron.d.ts"],
"exclude": ["**/__*/**", "__*"]
}