mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Implement usernames and profile sections
This commit is contained in:
@@ -1,20 +1,78 @@
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
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, fetchUserProfile } from "@/core/api/profileApi";
|
||||
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 } = useAppState();
|
||||
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);
|
||||
@@ -40,23 +98,19 @@ export function ProfileDialog() {
|
||||
}
|
||||
}, [chat.profileDialog, isOpen]);
|
||||
|
||||
const fetchFreshProfileData = async (profileData: ProfileDialogData) => {
|
||||
async function fetchFreshProfileData(profileData: ProfileDialogData) {
|
||||
if (!user.authToken) return;
|
||||
|
||||
try {
|
||||
let freshData = profileData;
|
||||
|
||||
// If it's not the public chat and has a username, fetch fresh data
|
||||
if (profileData.username && profileData.username !== "Общий чат" && profileData.userId) {
|
||||
const userProfile = await fetchUserProfile(user.authToken, profileData.username);
|
||||
// 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 = {
|
||||
userId: userProfile.id,
|
||||
username: userProfile.username,
|
||||
profilePicture: userProfile.profile_picture,
|
||||
bio: userProfile.bio,
|
||||
...userProfile,
|
||||
memberSince: userProfile.created_at,
|
||||
online: userProfile.online,
|
||||
isOwnProfile: profileData.isOwnProfile
|
||||
};
|
||||
}
|
||||
@@ -72,7 +126,7 @@ export function ProfileDialog() {
|
||||
setCurrentData(profileData);
|
||||
setIsOpen(true);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Trigger transition after component mounts
|
||||
useEffect(() => {
|
||||
@@ -90,13 +144,13 @@ export function ProfileDialog() {
|
||||
|
||||
// Handle ESC key
|
||||
useEffect(() => {
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && isOpen) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
function handleEsc(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
handleClose();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleEsc);
|
||||
return () => document.removeEventListener("keydown", handleEsc);
|
||||
}
|
||||
@@ -117,6 +171,13 @@ export function ProfileDialog() {
|
||||
}
|
||||
}, [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;
|
||||
|
||||
@@ -127,13 +188,14 @@ export function ProfileDialog() {
|
||||
};
|
||||
|
||||
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]);
|
||||
|
||||
const handleClose = async () => {
|
||||
async function handleClose() {
|
||||
if (hasChanges) {
|
||||
try {
|
||||
await confirm({
|
||||
@@ -151,7 +213,7 @@ export function ProfileDialog() {
|
||||
}
|
||||
};
|
||||
|
||||
const triggerCloseAnimation = () => {
|
||||
function triggerCloseAnimation() {
|
||||
if (backdropRef.current && dialogRef.current) {
|
||||
backdropRef.current.classList.remove('open');
|
||||
dialogRef.current.classList.remove('open');
|
||||
@@ -165,29 +227,41 @@ export function ProfileDialog() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
function handleBackdropClick(e: React.MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleUsernameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
function handleDisplayNameChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
if (!currentData) return;
|
||||
setCurrentData({ ...currentData, username: e.target.value });
|
||||
const newValue = e.target.value;
|
||||
setCurrentData({ ...currentData, display_name: newValue });
|
||||
|
||||
// Validate display name in real-time
|
||||
validateDisplayName(newValue);
|
||||
};
|
||||
|
||||
const handleBioChange = (newBio: string) => {
|
||||
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 });
|
||||
};
|
||||
|
||||
const handleProfilePictureClick = () => {
|
||||
function handleProfilePictureClick() {
|
||||
if (currentData?.isOwnProfile) {
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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
|
||||
@@ -202,15 +276,60 @@ export function ProfileDialog() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
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.nickname = currentData.username;
|
||||
updateData.username = currentData.username;
|
||||
}
|
||||
if (originalData.bio !== currentData.bio) {
|
||||
updateData.description = currentData.bio;
|
||||
@@ -233,22 +352,51 @@ export function ProfileDialog() {
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
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;
|
||||
|
||||
@@ -281,57 +429,64 @@ export function ProfileDialog() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Username */}
|
||||
{currentData.username && (
|
||||
<div className="username-section">
|
||||
<input
|
||||
className="username-input"
|
||||
type="text"
|
||||
value={currentData.username}
|
||||
onChange={handleUsernameChange}
|
||||
readOnly={!currentData.isOwnProfile}
|
||||
placeholder="Имя пользователя"
|
||||
/>
|
||||
</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?.userId || currentData?.isOwnProfile) && (
|
||||
<div className="online-status-section">
|
||||
<OnlineStatus userId={currentData.userId} />
|
||||
<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 && (
|
||||
<div className="section bio">
|
||||
<mdui-icon name="info--filled" />
|
||||
<div className="content-container">
|
||||
<label className="label">О себе:</label>
|
||||
<RichTextArea
|
||||
text={currentData.bio || ""}
|
||||
onTextChange={handleBioChange}
|
||||
placeholder="Нет информации о себе"
|
||||
className="value"
|
||||
rows={1}
|
||||
readOnly={!currentData.isOwnProfile}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Section
|
||||
type="bio"
|
||||
icon="info--filled"
|
||||
label="О себе:"
|
||||
value={currentData.bio}
|
||||
onChange={handleBioChange}
|
||||
readOnly={!currentData.isOwnProfile}
|
||||
placeholder="Нет информации о себе"
|
||||
textArea
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Member Since */}
|
||||
{currentData.memberSince && (
|
||||
<div className="section member-since">
|
||||
<mdui-icon name="calendar_month--filled" />
|
||||
<div className="content-container">
|
||||
<span className="label">Участник с:</span>
|
||||
<span className="value">
|
||||
{formatDate(currentData.memberSince)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Section
|
||||
type="member-since"
|
||||
icon="calendar_month--filled"
|
||||
label="Участник с:"
|
||||
value={formatDate(currentData.memberSince)}
|
||||
readOnly={true}
|
||||
placeholder="Участник с:"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -340,7 +495,7 @@ export function ProfileDialog() {
|
||||
{currentData.isOwnProfile && (
|
||||
<mdui-fab
|
||||
icon="check"
|
||||
className={`profile-dialog-fab ${hasChanges ? "visible" : ""}`}
|
||||
className={`profile-dialog-fab ${fabVisible ? "visible" : ""}`}
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user