mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
517 lines
19 KiB
TypeScript
517 lines
19 KiB
TypeScript
import { useState, useEffect, useRef, useMemo, type ReactNode } from "react";
|
||
import { createPortal } from "react-dom";
|
||
import { useAppState } from "@/pages/chat/state";
|
||
import type { ProfileDialogData } from "@/pages/chat/state";
|
||
import defaultAvatar from "@/images/default-avatar.png";
|
||
import { confirm } from "mdui/functions/confirm";
|
||
import { updateProfile, uploadProfilePicture, fetchUserProfileById } from "@/core/api/profileApi";
|
||
import { RichTextArea } from "@/core/components/RichTextArea";
|
||
import { onlineStatusManager } from "@/core/onlineStatusManager";
|
||
import { OnlineStatus } from "./right/OnlineStatus";
|
||
|
||
interface SectionProps {
|
||
type: string;
|
||
icon: string;
|
||
label: string;
|
||
error?: string;
|
||
value?: string;
|
||
onChange?: (value: string) => void;
|
||
readOnly: boolean;
|
||
placeholder: string;
|
||
textArea?: boolean;
|
||
}
|
||
|
||
function Section({ type, icon, label, error, value, onChange, readOnly, placeholder, textArea = false }: SectionProps) {
|
||
let valueComponent: ReactNode = null;
|
||
|
||
if (onChange) {
|
||
if (textArea) {
|
||
valueComponent = (
|
||
<RichTextArea
|
||
text={value || ""}
|
||
onTextChange={onChange}
|
||
placeholder={placeholder}
|
||
className="value"
|
||
rows={1}
|
||
readOnly={readOnly}
|
||
/>
|
||
);
|
||
} else {
|
||
valueComponent = (
|
||
<input
|
||
className="value"
|
||
type="text"
|
||
value={value}
|
||
onChange={e => onChange(e.target.value)}
|
||
readOnly={readOnly} />
|
||
);
|
||
}
|
||
} else {
|
||
valueComponent = (
|
||
<span className="value">{value}</span>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className={`section ${type} ${error ? 'error' : ''}`}>
|
||
<mdui-icon name={icon} />
|
||
<div className="content-container">
|
||
<label className="label">{label}</label>
|
||
{valueComponent}
|
||
{error && (
|
||
<div className="error-message">{error}</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function ProfileDialog() {
|
||
const { chat, user, closeProfileDialog, setUser } = useAppState();
|
||
const [isOpen, setIsOpen] = useState(false);
|
||
const [originalData, setOriginalData] = useState<ProfileDialogData | null>(null);
|
||
const [currentData, setCurrentData] = useState<ProfileDialogData | null>(null);
|
||
const [isSaving, setIsSaving] = useState(false);
|
||
const [errors, setErrors] = useState<{[key: string]: string}>({});
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const backdropRef = useRef<HTMLDivElement>(null);
|
||
const dialogRef = useRef<HTMLDivElement>(null);
|
||
|
||
// Handle dialog open/close based on state
|
||
useEffect(() => {
|
||
if (chat.profileDialog && !isOpen) {
|
||
// Fetch fresh data when opening dialog
|
||
fetchFreshProfileData(chat.profileDialog);
|
||
} else if (!chat.profileDialog && isOpen) {
|
||
// Start close animation
|
||
if (backdropRef.current && dialogRef.current) {
|
||
backdropRef.current.classList.remove('open');
|
||
dialogRef.current.classList.remove('open');
|
||
|
||
// Wait for animation to complete before closing
|
||
setTimeout(() => {
|
||
setIsOpen(false);
|
||
}, 300); // Match CSS transition duration
|
||
} else {
|
||
setIsOpen(false);
|
||
}
|
||
}
|
||
}, [chat.profileDialog, isOpen]);
|
||
|
||
async function fetchFreshProfileData(profileData: ProfileDialogData) {
|
||
if (!user.authToken) return;
|
||
|
||
try {
|
||
let freshData = profileData;
|
||
|
||
// If it's not the public chat and has a user ID, fetch fresh data
|
||
if (profileData.userId && profileData.username !== "Общий чат") {
|
||
const userProfile = await fetchUserProfileById(user.authToken, profileData.userId);
|
||
if (userProfile) {
|
||
freshData = {
|
||
...userProfile,
|
||
memberSince: userProfile.created_at,
|
||
isOwnProfile: profileData.isOwnProfile
|
||
};
|
||
}
|
||
}
|
||
|
||
setOriginalData(freshData);
|
||
setCurrentData(freshData);
|
||
setIsOpen(true);
|
||
} catch (error) {
|
||
console.error("Failed to fetch fresh profile data:", error);
|
||
// Fallback to cached data if fetch fails
|
||
setOriginalData(profileData);
|
||
setCurrentData(profileData);
|
||
setIsOpen(true);
|
||
}
|
||
}
|
||
|
||
// Trigger transition after component mounts
|
||
useEffect(() => {
|
||
if (isOpen) {
|
||
// Small delay to ensure DOM is ready for transition
|
||
const timer = setTimeout(() => {
|
||
if (backdropRef.current && dialogRef.current) {
|
||
backdropRef.current.classList.add('open');
|
||
dialogRef.current.classList.add('open');
|
||
}
|
||
}, 10);
|
||
return () => clearTimeout(timer);
|
||
}
|
||
}, [isOpen]);
|
||
|
||
// Handle ESC key
|
||
useEffect(() => {
|
||
if (isOpen) {
|
||
function handleEsc(e: KeyboardEvent) {
|
||
if (e.key === "Escape") {
|
||
handleClose();
|
||
}
|
||
}
|
||
|
||
document.addEventListener("keydown", handleEsc);
|
||
return () => document.removeEventListener("keydown", handleEsc);
|
||
}
|
||
}, [isOpen]);
|
||
|
||
// Subscribe to user's online status when dialog opens
|
||
useEffect(() => {
|
||
if (isOpen && currentData?.userId && !currentData.isOwnProfile) {
|
||
// Subscribe to the user's status
|
||
onlineStatusManager.subscribe(currentData.userId);
|
||
|
||
// Cleanup function to unsubscribe when dialog closes
|
||
return () => {
|
||
if (currentData.userId) {
|
||
onlineStatusManager.unsubscribe(currentData.userId);
|
||
}
|
||
};
|
||
}
|
||
}, [isOpen, currentData?.userId, currentData?.isOwnProfile]);
|
||
|
||
// Validate fields when data changes
|
||
useEffect(() => {
|
||
if (currentData && isOpen) {
|
||
validateFields();
|
||
}
|
||
}, [currentData, isOpen]);
|
||
|
||
const hasChanges = useMemo(() => {
|
||
if (!originalData || !currentData) return false;
|
||
|
||
// Normalize values for comparison (handle empty strings, undefined, null)
|
||
const normalizeValue = (value: string | undefined | null) => {
|
||
if (value === null || value === undefined) return "";
|
||
return value.trim();
|
||
};
|
||
|
||
return (
|
||
normalizeValue(originalData.display_name) !== normalizeValue(currentData.display_name) ||
|
||
normalizeValue(originalData.username) !== normalizeValue(currentData.username) ||
|
||
normalizeValue(originalData.bio) !== normalizeValue(currentData.bio) ||
|
||
originalData.profilePicture !== currentData.profilePicture
|
||
);
|
||
}, [originalData, currentData]);
|
||
|
||
async function handleClose() {
|
||
if (hasChanges) {
|
||
try {
|
||
await confirm({
|
||
headline: "Несохраненные изменения",
|
||
description: "У вас есть несохраненные изменения. Вы уверены, что хотите закрыть?",
|
||
confirmText: "Закрыть",
|
||
cancelText: "Отмена"
|
||
});
|
||
triggerCloseAnimation();
|
||
} catch {
|
||
// User cancelled, do nothing
|
||
}
|
||
} else {
|
||
triggerCloseAnimation();
|
||
}
|
||
};
|
||
|
||
function triggerCloseAnimation() {
|
||
if (backdropRef.current && dialogRef.current) {
|
||
backdropRef.current.classList.remove('open');
|
||
dialogRef.current.classList.remove('open');
|
||
|
||
// Wait for animation to complete before closing
|
||
setTimeout(() => {
|
||
closeProfileDialog();
|
||
}, 300); // Match CSS transition duration
|
||
} else {
|
||
closeProfileDialog();
|
||
}
|
||
};
|
||
|
||
function handleBackdropClick(e: React.MouseEvent) {
|
||
if (e.target === e.currentTarget) {
|
||
handleClose();
|
||
}
|
||
};
|
||
|
||
function handleDisplayNameChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||
if (!currentData) return;
|
||
const newValue = e.target.value;
|
||
setCurrentData({ ...currentData, display_name: newValue });
|
||
|
||
// Validate display name in real-time
|
||
validateDisplayName(newValue);
|
||
};
|
||
|
||
function handleUsernameChange(value: string) {
|
||
if (!currentData) return;
|
||
setCurrentData({ ...currentData, username: value });
|
||
|
||
// Validate username in real-time
|
||
validateUsername(value);
|
||
};
|
||
|
||
function handleBioChange(newBio: string) {
|
||
if (!currentData) return;
|
||
setCurrentData({ ...currentData, bio: newBio });
|
||
};
|
||
|
||
function handleProfilePictureClick() {
|
||
if (currentData?.isOwnProfile) {
|
||
fileInputRef.current?.click();
|
||
}
|
||
};
|
||
|
||
function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
|
||
const file = e.target.files?.[0];
|
||
if (file && file.type.startsWith("image/")) {
|
||
// Open cropper dialog here - for now just update the image
|
||
const reader = new FileReader();
|
||
reader.onload = (event) => {
|
||
const imageUrl = event.target?.result as string;
|
||
if (currentData) {
|
||
setCurrentData({ ...currentData, profilePicture: imageUrl });
|
||
}
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
};
|
||
|
||
function validateDisplayName(value: string) {
|
||
let error = "";
|
||
|
||
if (!value || value.trim().length === 0) {
|
||
error = "Отображаемое имя не может быть пустым";
|
||
} else if (value.length > 64) {
|
||
error = "Отображаемое имя не может быть длиннее 64 символов";
|
||
}
|
||
|
||
setErrors(prev => ({ ...prev, display_name: error }));
|
||
};
|
||
|
||
function validateUsername(value: string) {
|
||
let error = "";
|
||
|
||
if (!value || value.trim().length === 0) {
|
||
error = "Имя пользователя не может быть пустым";
|
||
} else if (value.length < 3) {
|
||
error = "Имя пользователя должно быть не менее 3 символов";
|
||
} else if (value.length > 20) {
|
||
error = "Имя пользователя не может быть длиннее 20 символов";
|
||
} else if (!/^[a-zA-Z0-9_-]+$/.test(value)) {
|
||
error = "Имя пользователя может содержать только английские буквы, цифры, дефисы и подчеркивания";
|
||
}
|
||
|
||
setErrors(prev => ({ ...prev, username: error }));
|
||
};
|
||
|
||
function validateFields() {
|
||
if (currentData) {
|
||
validateDisplayName(currentData.display_name || "");
|
||
validateUsername(currentData.username || "");
|
||
}
|
||
|
||
return !errors.display_name && !errors.username;
|
||
};
|
||
|
||
async function handleSave() {
|
||
if (!currentData || !user.authToken || !originalData) return;
|
||
|
||
// Validate fields first
|
||
if (!validateFields()) {
|
||
return;
|
||
}
|
||
|
||
setIsSaving(true);
|
||
try {
|
||
// Update profile data
|
||
const updateData: any = {};
|
||
if (originalData.display_name !== currentData.display_name) {
|
||
updateData.display_name = currentData.display_name;
|
||
}
|
||
if (originalData.username !== currentData.username) {
|
||
updateData.username = currentData.username;
|
||
}
|
||
if (originalData.bio !== currentData.bio) {
|
||
updateData.description = currentData.bio;
|
||
}
|
||
|
||
if (Object.keys(updateData).length > 0) {
|
||
await updateProfile(user.authToken, updateData);
|
||
}
|
||
|
||
// Update profile picture if changed
|
||
if (originalData.profilePicture !== currentData.profilePicture && currentData.profilePicture) {
|
||
// Convert data URL to blob if needed
|
||
if (currentData.profilePicture.startsWith("data:")) {
|
||
const response = await fetch(currentData.profilePicture);
|
||
const blob = await response.blob();
|
||
await uploadProfilePicture(user.authToken, blob);
|
||
}
|
||
}
|
||
|
||
// Update the original data to match current data
|
||
setOriginalData(currentData);
|
||
|
||
// If this is the current user's profile and username was changed, update the current user data
|
||
if (currentData.isOwnProfile && user.currentUser && user.authToken) {
|
||
const updatedUser = {
|
||
...user.currentUser,
|
||
username: currentData.username || user.currentUser.username,
|
||
display_name: currentData.display_name || user.currentUser.display_name,
|
||
bio: currentData.bio || user.currentUser.bio,
|
||
profile_picture: currentData.profilePicture || user.currentUser.profile_picture
|
||
};
|
||
setUser(user.authToken, updatedUser);
|
||
}
|
||
|
||
// Close dialog with animation after successful save
|
||
triggerCloseAnimation();
|
||
} catch (error) {
|
||
console.error("Failed to save profile:", error);
|
||
// Handle API errors
|
||
if (error instanceof Error && error.message.includes("уже занято")) {
|
||
setErrors({ username: "Это имя пользователя уже занято" });
|
||
} else {
|
||
setErrors({ general: "Ошибка при сохранении профиля" });
|
||
}
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
}
|
||
|
||
function formatDate(dateString: string) {
|
||
return new Date(dateString).toLocaleDateString("ru-RU", {
|
||
year: "numeric",
|
||
month: "long",
|
||
day: "numeric"
|
||
});
|
||
}
|
||
|
||
const fabVisible = useMemo(() => {
|
||
let hasErrors = false;
|
||
Object.values(errors).forEach(error => {
|
||
if (error) {
|
||
hasErrors = true;
|
||
}
|
||
});
|
||
|
||
return hasChanges && currentData?.isOwnProfile && !isSaving && !hasErrors;
|
||
}, [hasChanges, currentData?.isOwnProfile, isSaving, errors]);
|
||
|
||
if (!isOpen || !currentData) return null;
|
||
|
||
return createPortal(
|
||
<div
|
||
ref={backdropRef}
|
||
className="profile-dialog-backdrop"
|
||
onClick={handleBackdropClick}
|
||
>
|
||
<div ref={dialogRef} className="profile-dialog">
|
||
<div className="profile-dialog-content">
|
||
{/* Profile Picture */}
|
||
<div className="profile-picture-section">
|
||
<img
|
||
className="profile-picture"
|
||
src={currentData.profilePicture || defaultAvatar}
|
||
alt="Profile Picture"
|
||
onError={(e) => {
|
||
const target = e.target as HTMLImageElement;
|
||
target.src = defaultAvatar;
|
||
}}
|
||
/>
|
||
{currentData.isOwnProfile && (
|
||
<div
|
||
className="profile-picture-edit-overlay"
|
||
onClick={handleProfilePictureClick}
|
||
>
|
||
<mdui-icon name="camera_alt--filled" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Display Name */}
|
||
<div className={`username-section ${errors.display_name ? 'error' : ''}`}>
|
||
<input
|
||
className="username-input"
|
||
type="text"
|
||
value={currentData.display_name}
|
||
onChange={handleDisplayNameChange}
|
||
readOnly={!currentData.isOwnProfile}
|
||
placeholder="Имя"
|
||
/>
|
||
{errors.display_name && (
|
||
<div className="error-message">{errors.display_name}</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Online Status */}
|
||
{(currentData?.userId || currentData?.isOwnProfile) && (
|
||
<div className="online-status-section">
|
||
<OnlineStatus userId={currentData.userId || user.currentUser!.id} />
|
||
</div>
|
||
)}
|
||
|
||
<div className="profile-sections">
|
||
<Section
|
||
type="username"
|
||
error={errors.username}
|
||
icon="alternate_email--filled"
|
||
label="Имя пользователя:"
|
||
value={currentData.username}
|
||
onChange={handleUsernameChange}
|
||
readOnly={!currentData.isOwnProfile}
|
||
placeholder="username" />
|
||
|
||
|
||
{/* Bio */}
|
||
{currentData.bio !== undefined && (
|
||
<Section
|
||
type="bio"
|
||
icon="info--filled"
|
||
label="О себе:"
|
||
value={currentData.bio}
|
||
onChange={handleBioChange}
|
||
readOnly={!currentData.isOwnProfile}
|
||
placeholder="Нет информации о себе"
|
||
textArea
|
||
/>
|
||
)}
|
||
|
||
{/* Member Since */}
|
||
{currentData.memberSince && (
|
||
<Section
|
||
type="member-since"
|
||
icon="calendar_month--filled"
|
||
label="Участник с:"
|
||
value={formatDate(currentData.memberSince)}
|
||
readOnly={true}
|
||
placeholder="Участник с:"
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Save FAB */}
|
||
{currentData.isOwnProfile && (
|
||
<mdui-fab
|
||
icon="check"
|
||
className={`profile-dialog-fab ${fabVisible ? "visible" : ""}`}
|
||
onClick={handleSave}
|
||
disabled={isSaving}
|
||
/>
|
||
)}
|
||
|
||
{/* Hidden file input */}
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
style={{ display: "none" }}
|
||
onChange={handleFileSelect}
|
||
/>
|
||
</div>
|
||
</div>,
|
||
document.getElementById("root")!
|
||
);
|
||
}
|