внедрил систему статусов (BIO)
This commit is contained in:
@@ -9,4 +9,6 @@ public static class ProfilesErrors
|
|||||||
public static Error AvatarNotFound => new("Profiles.AvatarNotFound", "Avatar not found");
|
public static Error AvatarNotFound => new("Profiles.AvatarNotFound", "Avatar not found");
|
||||||
public static Error InvalidAvatarFormat => new("Profiles.InvalidAvatarFormat", "Invalid avatar format");
|
public static Error InvalidAvatarFormat => new("Profiles.InvalidAvatarFormat", "Invalid avatar format");
|
||||||
public static Error AvatarUploadFailed => new("Profiles.AvatarUploadFailed", "Avatar upload failed");
|
public static Error AvatarUploadFailed => new("Profiles.AvatarUploadFailed", "Avatar upload failed");
|
||||||
|
public static Error BioTooLong => new("Profiles.BioTooLong", "Bio must be 200 characters or less");
|
||||||
|
public static Error StatusTextTooLong => new("Profiles.StatusTextTooLong", "Status text must be 50 characters or less");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,5 +12,8 @@ public class UserProfileDto
|
|||||||
public DateTime? LastSeen { get; set; }
|
public DateTime? LastSeen { get; set; }
|
||||||
public DateTime? Birthday { get; set; }
|
public DateTime? Birthday { get; set; }
|
||||||
public bool IsPremium { get; set; }
|
public bool IsPremium { get; set; }
|
||||||
|
public string? StatusText { get; set; }
|
||||||
|
public string? StatusEmoji { get; set; }
|
||||||
|
public DateTime? StatusExpiresAt { get; set; }
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,5 +2,11 @@ using System;
|
|||||||
|
|
||||||
namespace Knot.Modules.Profiles.Application.Profiles.DTOs;
|
namespace Knot.Modules.Profiles.Application.Profiles.DTOs;
|
||||||
|
|
||||||
public record UpdateProfileRequest(string? DisplayName, string? Bio, DateTime? Birthday);
|
public record UpdateProfileRequest(
|
||||||
|
string? DisplayName,
|
||||||
|
string? Bio,
|
||||||
|
DateTime? Birthday,
|
||||||
|
string? StatusText,
|
||||||
|
string? StatusEmoji,
|
||||||
|
DateTime? StatusExpiresAt);
|
||||||
public record UpdateSettingsRequest(bool? HideStoryViews);
|
public record UpdateSettingsRequest(bool? HideStoryViews);
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ public sealed record UpdateProfileCommand(
|
|||||||
Guid UserId,
|
Guid UserId,
|
||||||
string? DisplayName,
|
string? DisplayName,
|
||||||
string? Bio,
|
string? Bio,
|
||||||
DateTime? Birthday) : ICommand<UserProfileDto>;
|
DateTime? Birthday,
|
||||||
|
string? StatusText,
|
||||||
|
string? StatusEmoji,
|
||||||
|
DateTime? StatusExpiresAt) : ICommand<UserProfileDto>;
|
||||||
|
|
||||||
internal sealed class UpdateProfileCommandHandler : ICommandHandler<UpdateProfileCommand, UserProfileDto>
|
internal sealed class UpdateProfileCommandHandler : ICommandHandler<UpdateProfileCommand, UserProfileDto>
|
||||||
{
|
{
|
||||||
@@ -28,9 +31,18 @@ internal sealed class UpdateProfileCommandHandler : ICommandHandler<UpdateProfil
|
|||||||
if (profile is null)
|
if (profile is null)
|
||||||
return Result.Failure<UserProfileDto>(ProfilesErrors.ProfileNotFound);
|
return Result.Failure<UserProfileDto>(ProfilesErrors.ProfileNotFound);
|
||||||
|
|
||||||
|
if ((request.Bio?.Length ?? 0) > 200)
|
||||||
|
return Result.Failure<UserProfileDto>(ProfilesErrors.BioTooLong);
|
||||||
|
|
||||||
|
if ((request.StatusText?.Length ?? 0) > 50)
|
||||||
|
return Result.Failure<UserProfileDto>(ProfilesErrors.StatusTextTooLong);
|
||||||
|
|
||||||
profile.DisplayName = request.DisplayName ?? profile.DisplayName;
|
profile.DisplayName = request.DisplayName ?? profile.DisplayName;
|
||||||
profile.About = request.Bio;
|
profile.About = request.Bio;
|
||||||
profile.Birthday = request.Birthday;
|
profile.Birthday = request.Birthday;
|
||||||
|
profile.StatusText = request.StatusText;
|
||||||
|
profile.StatusEmoji = request.StatusEmoji;
|
||||||
|
profile.StatusExpiresAt = request.StatusExpiresAt;
|
||||||
|
|
||||||
var result = await _repository.UpdateAsync(profile, cancellationToken);
|
var result = await _repository.UpdateAsync(profile, cancellationToken);
|
||||||
if (result.IsFailure)
|
if (result.IsFailure)
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ public sealed class ProfileDocument
|
|||||||
|
|
||||||
public string? Bio { get; private set; }
|
public string? Bio { get; private set; }
|
||||||
|
|
||||||
|
public string? StatusText { get; private set; }
|
||||||
|
|
||||||
|
public string? StatusEmoji { get; private set; }
|
||||||
|
|
||||||
|
public DateTime? StatusExpiresAt { get; private set; }
|
||||||
|
|
||||||
public string? AvatarUrl { get; private set; }
|
public string? AvatarUrl { get; private set; }
|
||||||
|
|
||||||
public DateTime? Birthday { get; private set; }
|
public DateTime? Birthday { get; private set; }
|
||||||
@@ -65,6 +71,13 @@ public sealed class ProfileDocument
|
|||||||
public void UpdateSettings(bool hideStoryViews)
|
public void UpdateSettings(bool hideStoryViews)
|
||||||
=> HideStoryViews = hideStoryViews;
|
=> HideStoryViews = hideStoryViews;
|
||||||
|
|
||||||
|
public void UpdateStatus(string? statusText, string? statusEmoji, DateTime? statusExpiresAt)
|
||||||
|
{
|
||||||
|
StatusText = statusText;
|
||||||
|
StatusEmoji = statusEmoji;
|
||||||
|
StatusExpiresAt = statusExpiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
public void UpdateStatus(bool isBanned, bool isDeleted)
|
public void UpdateStatus(bool isBanned, bool isDeleted)
|
||||||
{
|
{
|
||||||
IsBanned = isBanned;
|
IsBanned = isBanned;
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ internal class ProfileRepository : IProfileRepository
|
|||||||
dto.Birthday);
|
dto.Birthday);
|
||||||
|
|
||||||
document.UpdateAvatar(dto.Avatar);
|
document.UpdateAvatar(dto.Avatar);
|
||||||
|
document.UpdateStatus(dto.StatusText, dto.StatusEmoji, dto.StatusExpiresAt);
|
||||||
|
|
||||||
await _profiles.ReplaceOneAsync(p => p.Id == dto.UserId, document, cancellationToken: ct);
|
await _profiles.ReplaceOneAsync(p => p.Id == dto.UserId, document, cancellationToken: ct);
|
||||||
return Result.Success(document.ToDto());
|
return Result.Success(document.ToDto());
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Knot.Contracts.Profiles.Application.DTOs;
|
using Knot.Contracts.Profiles.Application.DTOs;
|
||||||
using Knot.Modules.Profiles.Domain;
|
using Knot.Modules.Profiles.Domain;
|
||||||
|
using System;
|
||||||
|
|
||||||
namespace Knot.Modules.Profiles.Infrastructure.Mappings;
|
namespace Knot.Modules.Profiles.Infrastructure.Mappings;
|
||||||
|
|
||||||
@@ -7,6 +8,8 @@ public static class ProfileMappings
|
|||||||
{
|
{
|
||||||
public static UserProfileDto ToDto(this ProfileDocument document)
|
public static UserProfileDto ToDto(this ProfileDocument document)
|
||||||
{
|
{
|
||||||
|
var hasActiveStatus = !document.StatusExpiresAt.HasValue || document.StatusExpiresAt.Value > DateTime.UtcNow;
|
||||||
|
|
||||||
return new UserProfileDto
|
return new UserProfileDto
|
||||||
{
|
{
|
||||||
UserId = document.Id,
|
UserId = document.Id,
|
||||||
@@ -18,6 +21,9 @@ public static class ProfileMappings
|
|||||||
LastSeen = null,
|
LastSeen = null,
|
||||||
Birthday = document.Birthday,
|
Birthday = document.Birthday,
|
||||||
IsPremium = false,
|
IsPremium = false,
|
||||||
|
StatusText = hasActiveStatus ? document.StatusText : null,
|
||||||
|
StatusEmoji = hasActiveStatus ? document.StatusEmoji : null,
|
||||||
|
StatusExpiresAt = hasActiveStatus ? document.StatusExpiresAt : null,
|
||||||
CreatedAt = document.CreatedAt
|
CreatedAt = document.CreatedAt
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ public static class ProfilesEndpoints
|
|||||||
public static void MapProfilesEndpoints(this WebApplication app)
|
public static void MapProfilesEndpoints(this WebApplication app)
|
||||||
{
|
{
|
||||||
var group = app.MapGroup("api/profiles").RequireAuthorization();
|
var group = app.MapGroup("api/profiles").RequireAuthorization();
|
||||||
|
var userGroup = app.MapGroup("api/user").RequireAuthorization();
|
||||||
|
var usersGroup = app.MapGroup("api/users").RequireAuthorization();
|
||||||
|
|
||||||
group.MapGet("search", async ([FromQuery] string q, ISender sender, IUserContext userContext, CancellationToken ct) =>
|
group.MapGet("search", async ([FromQuery] string q, ISender sender, IUserContext userContext, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
@@ -67,16 +69,39 @@ public static class ProfilesEndpoints
|
|||||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error);
|
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error);
|
||||||
});
|
});
|
||||||
|
|
||||||
group.MapPut("profile", async ([FromBody] Knot.Modules.Profiles.Application.Profiles.DTOs.UpdateProfileRequest request, ISender sender, IUserContext userContext, CancellationToken ct) =>
|
async Task<IResult> UpdateProfileHandler(
|
||||||
|
[FromBody] Knot.Modules.Profiles.Application.Profiles.DTOs.UpdateProfileRequest request,
|
||||||
|
ISender sender,
|
||||||
|
IUserContext userContext,
|
||||||
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new UpdateProfileCommand(userContext.UserId, request.DisplayName, request.Bio, request.Birthday), ct);
|
var result = await sender.Send(
|
||||||
|
new UpdateProfileCommand(
|
||||||
|
userContext.UserId,
|
||||||
|
request.DisplayName,
|
||||||
|
request.Bio,
|
||||||
|
request.Birthday,
|
||||||
|
request.StatusText,
|
||||||
|
request.StatusEmoji,
|
||||||
|
request.StatusExpiresAt),
|
||||||
|
ct);
|
||||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error);
|
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error);
|
||||||
});
|
}
|
||||||
|
|
||||||
|
group.MapPut("profile", UpdateProfileHandler);
|
||||||
|
group.MapPatch("profile", UpdateProfileHandler);
|
||||||
|
userGroup.MapPatch("profile", UpdateProfileHandler);
|
||||||
|
|
||||||
group.MapGet("{id:guid}", async (Guid id, ISender sender, CancellationToken ct) =>
|
group.MapGet("{id:guid}", async (Guid id, ISender sender, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new GetProfileQuery(id), ct);
|
var result = await sender.Send(new GetProfileQuery(id), ct);
|
||||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error);
|
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
usersGroup.MapGet("{id:guid}", async (Guid id, ISender sender, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new GetProfileQuery(id), ct);
|
||||||
|
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ export interface UserPresence extends UserBasic {
|
|||||||
export interface User extends UserPresence {
|
export interface User extends UserPresence {
|
||||||
bio: string | null;
|
bio: string | null;
|
||||||
birthday: string | null;
|
birthday: string | null;
|
||||||
|
statusText?: string | null;
|
||||||
|
statusEmoji?: string | null;
|
||||||
|
statusExpiresAt?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
hideStoryViews?: boolean;
|
hideStoryViews?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,17 @@ export function normalizeUser(user: any): any {
|
|||||||
result.about = result.bio;
|
result.about = result.bio;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. Приведение status (snake_case -> camelCase)
|
||||||
|
if (!result.statusText && result.status_text) {
|
||||||
|
result.statusText = result.status_text;
|
||||||
|
}
|
||||||
|
if (!result.statusEmoji && result.status_emoji) {
|
||||||
|
result.statusEmoji = result.status_emoji;
|
||||||
|
}
|
||||||
|
if (!result.statusExpiresAt && result.status_expires_at) {
|
||||||
|
result.statusExpiresAt = result.status_expires_at;
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import { ChatApi } from '../../infrastructure/chatApi';
|
|||||||
import ConfirmModal from '../../../../core/presentation/components/ui/ConfirmModal';
|
import ConfirmModal from '../../../../core/presentation/components/ui/ConfirmModal';
|
||||||
import Avatar from '../../../../core/presentation/components/ui/Avatar';
|
import Avatar from '../../../../core/presentation/components/ui/Avatar';
|
||||||
import type { Chat } from '../../../../core/domain/types';
|
import type { Chat } from '../../../../core/domain/types';
|
||||||
|
import { UserApi } from '../../../users/infrastructure/userApi';
|
||||||
|
|
||||||
|
const userStatusCache = new Map<string, { emoji?: string | null; text?: string | null }>();
|
||||||
|
|
||||||
interface ChatListItemProps {
|
interface ChatListItemProps {
|
||||||
chat: Chat;
|
chat: Chat;
|
||||||
@@ -24,6 +27,7 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
|||||||
|
|
||||||
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
|
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [otherUserStatus, setOtherUserStatus] = useState<{ emoji?: string | null; text?: string | null } | null>(null);
|
||||||
const ctxRef = useRef<HTMLDivElement>(null);
|
const ctxRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const myMember = chat.members.find((m) => m.user.id === user?.id);
|
const myMember = chat.members.find((m) => m.user.id === user?.id);
|
||||||
@@ -87,6 +91,38 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
|||||||
const [showAttachmentConfirm, setShowAttachmentConfirm] = useState(false);
|
const [showAttachmentConfirm, setShowAttachmentConfirm] = useState(false);
|
||||||
const [importStatus, setImportStatus] = useState<{ processed: number, total: number } | null>(null);
|
const [importStatus, setImportStatus] = useState<{ processed: number, total: number } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (chat.type !== 'personal' || !otherMember?.user?.id) {
|
||||||
|
setOtherUserStatus(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const otherId = otherMember.user.id;
|
||||||
|
const cached = userStatusCache.get(otherId);
|
||||||
|
if (cached) {
|
||||||
|
setOtherUserStatus(cached);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
UserApi.getUser(otherId)
|
||||||
|
.then((profile) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const status = { emoji: profile.statusEmoji, text: profile.statusText };
|
||||||
|
userStatusCache.set(otherId, status);
|
||||||
|
setOtherUserStatus(status);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setOtherUserStatus(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [chat.type, otherMember?.user?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!chat.isImporting || !chat.importJobId) {
|
if (!chat.isImporting || !chat.importJobId) {
|
||||||
setImportStatus(null);
|
setImportStatus(null);
|
||||||
@@ -210,6 +246,14 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-1.5 min-w-0">
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
{isPinned && <span className="material-symbols-outlined text-[14px] text-primary rotate-45">push_pin</span>}
|
{isPinned && <span className="material-symbols-outlined text-[14px] text-primary rotate-45">push_pin</span>}
|
||||||
|
{chat.type === 'personal' && otherUserStatus?.emoji && (
|
||||||
|
<span
|
||||||
|
className="text-sm leading-none"
|
||||||
|
title={otherUserStatus.text || undefined}
|
||||||
|
>
|
||||||
|
{otherUserStatus.emoji}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className={`text-sm font-bold tracking-tight truncate ${isActive ? 'text-primary' : 'text-on-surface'}`}>{chatName}</span>
|
<span className={`text-sm font-bold tracking-tight truncate ${isActive ? 'text-primary' : 'text-on-surface'}`}>{chatName}</span>
|
||||||
</div>
|
</div>
|
||||||
{timeStr && <span className="text-[10px] uppercase font-bold tracking-wider text-on-surface-variant/50 flex-shrink-0 ml-2">{timeStr}</span>}
|
{timeStr && <span className="text-[10px] uppercase font-bold tracking-wider text-on-surface-variant/50 flex-shrink-0 ml-2">{timeStr}</span>}
|
||||||
|
|||||||
@@ -8,18 +8,25 @@ export class UserApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static async getUser(id: string) {
|
static async getUser(id: string) {
|
||||||
// Конечная точка в новом бэкенде: GET /api/profiles/{id}
|
// Основной путь по ТЗ: GET /api/users/{id}; старый профильный путь оставлен на бэкенде.
|
||||||
return httpClient.request<User>(`/profiles/${id}`);
|
return httpClient.request<User>(`/users/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
static async updateProfile(data: { displayName?: string; bio?: string; birthday?: string | null }) {
|
static async updateProfile(data: {
|
||||||
// Конечная точка в новом бэкенде: PUT /api/profiles/profile
|
displayName?: string;
|
||||||
|
bio?: string;
|
||||||
|
birthday?: string | null;
|
||||||
|
statusText?: string | null;
|
||||||
|
statusEmoji?: string | null;
|
||||||
|
statusExpiresAt?: string | null;
|
||||||
|
}) {
|
||||||
|
// Основной путь по ТЗ: PATCH /api/user/profile
|
||||||
const payload = {
|
const payload = {
|
||||||
...data,
|
...data,
|
||||||
about: data.bio // Дублируем для совместимости
|
about: data.bio // Дублируем для совместимости
|
||||||
};
|
};
|
||||||
return httpClient.request<User>('/profiles/profile', {
|
return httpClient.request<User>('/user/profile', {
|
||||||
method: 'PUT',
|
method: 'PATCH',
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
User, Camera, Edit3, Calendar, Info,
|
User, Camera, Edit3, Calendar, Info,
|
||||||
MapPin, AtSign, Check, Loader2, LogOut,
|
MapPin, AtSign, Check, Loader2, LogOut,
|
||||||
ChevronRight, ArrowLeft, Languages, Palette, Trash2,
|
ChevronRight, ArrowLeft, Languages, Palette, Trash2,
|
||||||
Bell, Shield, Eye
|
Bell, Shield, Eye, Smile
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuthStore } from '../../../auth/application/authStore';
|
import { useAuthStore } from '../../../auth/application/authStore';
|
||||||
import { useLang } from '../../../../core/infrastructure/i18n';
|
import { useLang } from '../../../../core/infrastructure/i18n';
|
||||||
@@ -14,6 +14,16 @@ import DatePicker from '../../../../core/presentation/components/ui/DatePicker';
|
|||||||
import AvatarCropModal from './AvatarCropModal';
|
import AvatarCropModal from './AvatarCropModal';
|
||||||
import { Area } from 'react-easy-crop';
|
import { Area } from 'react-easy-crop';
|
||||||
import { getCroppedImg } from '../../../../core/utils/cropImage';
|
import { getCroppedImg } from '../../../../core/utils/cropImage';
|
||||||
|
import EmojiOnlyPicker from '../../../stories/presentation/components/EmojiOnlyPicker';
|
||||||
|
|
||||||
|
const BIO_MAX_LENGTH = 200;
|
||||||
|
const STATUS_MAX_LENGTH = 50;
|
||||||
|
const STATUS_PRESETS = [
|
||||||
|
{ emoji: '📞', textRu: 'На созвоне', textEn: 'On a call' },
|
||||||
|
{ emoji: '🍔', textRu: 'Обед', textEn: 'Lunch' },
|
||||||
|
{ emoji: '💻', textRu: 'Работаю', textEn: 'Working' },
|
||||||
|
{ emoji: '💤', textRu: 'Сплю', textEn: 'Sleeping' },
|
||||||
|
];
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { user, updateUser, logout } = useAuthStore();
|
const { user, updateUser, logout } = useAuthStore();
|
||||||
@@ -23,9 +33,13 @@ export default function SettingsPage() {
|
|||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
displayName: user?.displayName || '',
|
displayName: user?.displayName || '',
|
||||||
bio: user?.bio || '',
|
bio: user?.bio || '',
|
||||||
birthday: user?.birthday || ''
|
birthday: user?.birthday || '',
|
||||||
|
statusText: user?.statusText || '',
|
||||||
|
statusEmoji: user?.statusEmoji || '💬',
|
||||||
|
statusDuration: 'none' as 'none' | '1h' | '1d'
|
||||||
});
|
});
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [showStatusPicker, setShowStatusPicker] = useState(false);
|
||||||
|
|
||||||
// Avatar cropping state
|
// Avatar cropping state
|
||||||
const [cropModal, setCropModal] = useState<{
|
const [cropModal, setCropModal] = useState<{
|
||||||
@@ -41,7 +55,10 @@ export default function SettingsPage() {
|
|||||||
setFormData({
|
setFormData({
|
||||||
displayName: user.displayName || '',
|
displayName: user.displayName || '',
|
||||||
bio: user.bio || '',
|
bio: user.bio || '',
|
||||||
birthday: user.birthday || ''
|
birthday: user.birthday || '',
|
||||||
|
statusText: user.statusText || '',
|
||||||
|
statusEmoji: user.statusEmoji || '💬',
|
||||||
|
statusDuration: 'none'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
@@ -51,6 +68,15 @@ export default function SettingsPage() {
|
|||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
...formData,
|
...formData,
|
||||||
|
bio: formData.bio.slice(0, BIO_MAX_LENGTH),
|
||||||
|
statusText: formData.statusText.trim() ? formData.statusText.slice(0, STATUS_MAX_LENGTH) : null,
|
||||||
|
statusEmoji: formData.statusText.trim() ? formData.statusEmoji : null,
|
||||||
|
statusExpiresAt:
|
||||||
|
formData.statusText.trim() && formData.statusDuration !== 'none'
|
||||||
|
? new Date(
|
||||||
|
Date.now() + (formData.statusDuration === '1h' ? 60 * 60 * 1000 : 24 * 60 * 60 * 1000)
|
||||||
|
).toISOString()
|
||||||
|
: null,
|
||||||
birthday: formData.birthday || null
|
birthday: formData.birthday || null
|
||||||
};
|
};
|
||||||
const updatedUser = await UserApi.updateProfile(payload);
|
const updatedUser = await UserApi.updateProfile(payload);
|
||||||
@@ -237,7 +263,7 @@ export default function SettingsPage() {
|
|||||||
{editing ? (
|
{editing ? (
|
||||||
<textarea
|
<textarea
|
||||||
value={formData.bio}
|
value={formData.bio}
|
||||||
onChange={(e) => setFormData(p => ({ ...p, bio: e.target.value }))}
|
onChange={(e) => setFormData(p => ({ ...p, bio: e.target.value.slice(0, BIO_MAX_LENGTH) }))}
|
||||||
className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-3 text-sm font-medium text-white/80 focus:border-primary/50 transition-all outline-none resize-none h-32"
|
className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-3 text-sm font-medium text-white/80 focus:border-primary/50 transition-all outline-none resize-none h-32"
|
||||||
placeholder={t('tellAboutBio')}
|
placeholder={t('tellAboutBio')}
|
||||||
/>
|
/>
|
||||||
@@ -246,6 +272,11 @@ export default function SettingsPage() {
|
|||||||
{user?.bio || t('noBio')}
|
{user?.bio || t('noBio')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{editing && (
|
||||||
|
<p className="text-[10px] font-black uppercase tracking-widest text-white/35 text-right">
|
||||||
|
{formData.bio.length}/{BIO_MAX_LENGTH}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3 p-8 rounded-[2.5rem] bg-white/[0.02] border border-white/5 transition-colors hover:bg-white/[0.04]">
|
<div className="space-y-3 p-8 rounded-[2.5rem] bg-white/[0.02] border border-white/5 transition-colors hover:bg-white/[0.04]">
|
||||||
@@ -266,6 +297,87 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 p-8 rounded-[2.5rem] bg-white/[0.02] border border-white/5 transition-colors hover:bg-white/[0.04]">
|
||||||
|
<div className="flex items-center gap-3 text-primary mb-1">
|
||||||
|
<Smile size={16} />
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-[0.2em]">
|
||||||
|
{lang === 'ru' ? 'Статус' : 'Status'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||||
|
{STATUS_PRESETS.map((preset) => (
|
||||||
|
<button
|
||||||
|
key={`${preset.emoji}-${preset.textRu}`}
|
||||||
|
onClick={() => setFormData((p) => ({
|
||||||
|
...p,
|
||||||
|
statusEmoji: preset.emoji,
|
||||||
|
statusText: lang === 'ru' ? preset.textRu : preset.textEn,
|
||||||
|
}))}
|
||||||
|
className="px-3 py-2 rounded-xl bg-white/5 border border-white/10 text-xs font-bold text-white/80 hover:border-primary/40"
|
||||||
|
>
|
||||||
|
{preset.emoji} {lang === 'ru' ? preset.textRu : preset.textEn}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowStatusPicker(true)}
|
||||||
|
className="px-4 py-2 rounded-xl bg-white/5 border border-white/10 text-white text-sm font-bold"
|
||||||
|
>
|
||||||
|
{formData.statusEmoji}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.statusText}
|
||||||
|
onChange={(e) => setFormData((p) => ({ ...p, statusText: e.target.value.slice(0, STATUS_MAX_LENGTH) }))}
|
||||||
|
className="flex-1 bg-white/5 border border-white/10 rounded-2xl px-4 py-2.5 text-sm font-medium text-white/90 focus:border-primary/50 transition-all outline-none"
|
||||||
|
placeholder={lang === 'ru' ? 'Что у вас сейчас?' : 'What are you up to?'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[
|
||||||
|
{ id: 'none', label: lang === 'ru' ? 'Без таймера' : 'No timer' },
|
||||||
|
{ id: '1h', label: lang === 'ru' ? '1 час' : '1 hour' },
|
||||||
|
{ id: '1d', label: lang === 'ru' ? '1 день' : '1 day' },
|
||||||
|
].map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.id}
|
||||||
|
onClick={() => setFormData((p) => ({ ...p, statusDuration: opt.id as 'none' | '1h' | '1d' }))}
|
||||||
|
className={`px-3 py-2 rounded-xl text-xs font-black uppercase tracking-wider border ${
|
||||||
|
formData.statusDuration === opt.id
|
||||||
|
? 'bg-primary/20 border-primary/40 text-primary'
|
||||||
|
: 'bg-white/5 border-white/10 text-white/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-[10px] font-black uppercase tracking-widest text-white/35">
|
||||||
|
{formData.statusText.length}/{STATUS_MAX_LENGTH}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setFormData((p) => ({ ...p, statusText: '', statusEmoji: '💬', statusDuration: 'none' }))}
|
||||||
|
className="text-[10px] font-black uppercase tracking-widest text-red-400/80 hover:text-red-400"
|
||||||
|
>
|
||||||
|
{lang === 'ru' ? 'Очистить статус' : 'Clear status'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="inline-flex px-4 py-2 rounded-full bg-white/5 border border-white/10 text-sm font-bold text-white">
|
||||||
|
{user?.statusEmoji || '💬'} {user?.statusText || (lang === 'ru' ? 'Статус не указан' : 'No status')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* App Settings */}
|
{/* App Settings */}
|
||||||
<div className="space-y-6 pt-6">
|
<div className="space-y-6 pt-6">
|
||||||
<div className="flex items-center gap-4 mb-4">
|
<div className="flex items-center gap-4 mb-4">
|
||||||
@@ -324,6 +436,17 @@ export default function SettingsPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
<AnimatePresence>
|
||||||
|
{showStatusPicker && (
|
||||||
|
<EmojiOnlyPicker
|
||||||
|
onSelect={(emoji) => {
|
||||||
|
setFormData((p) => ({ ...p, statusEmoji: emoji }));
|
||||||
|
setShowStatusPicker(false);
|
||||||
|
}}
|
||||||
|
onClose={() => setShowStatusPicker(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { useLang } from '../../../../core/infrastructure/i18n';
|
|||||||
import { useAuthStore } from '../../../auth/application/authStore';
|
import { useAuthStore } from '../../../auth/application/authStore';
|
||||||
import { useFriendStore } from '../../../friends/application/friendStore';
|
import { useFriendStore } from '../../../friends/application/friendStore';
|
||||||
import { ChatApi } from '../../../chats/infrastructure/chatApi';
|
import { ChatApi } from '../../../chats/infrastructure/chatApi';
|
||||||
import { httpClient } from '../../../../core/infrastructure/httpClient';
|
import { UserApi } from '../../infrastructure/userApi';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
import ImageLightbox from '../../../../core/presentation/components/ui/ImageLightbox';
|
import ImageLightbox from '../../../../core/presentation/components/ui/ImageLightbox';
|
||||||
import type { FriendWithId, FriendRequest } from '../../../../core/domain/types';
|
import type { FriendWithId, FriendRequest } from '../../../../core/domain/types';
|
||||||
@@ -51,7 +51,7 @@ export default function UserProfile({ userId, onClose, onMessage, isSelf: isSelf
|
|||||||
|
|
||||||
const fetchUser = useCallback(async () => {
|
const fetchUser = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await httpClient.request<any>(`/profiles/${userId}`);
|
const res = await UserApi.getUser(userId);
|
||||||
setUser(res);
|
setUser(res);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -242,6 +242,13 @@ export default function UserProfile({ userId, onClose, onMessage, isSelf: isSelf
|
|||||||
<div className="px-4 py-1.5 rounded-full bg-primary/15 border border-primary/25">
|
<div className="px-4 py-1.5 rounded-full bg-primary/15 border border-primary/25">
|
||||||
<span className="text-xs font-black text-primary uppercase tracking-widest">@{user.username || 'user_' + userId.slice(0, 5)}</span>
|
<span className="text-xs font-black text-primary uppercase tracking-widest">@{user.username || 'user_' + userId.slice(0, 5)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{(user.statusEmoji || user.statusText) && (
|
||||||
|
<div className="px-4 py-1.5 rounded-full bg-white/5 border border-white/10">
|
||||||
|
<span className="text-xs font-black text-white tracking-wide">
|
||||||
|
{user.statusEmoji || '💬'} {user.statusText || (lang === 'ru' ? 'Статус' : 'Status')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex items-center gap-2 text-xs text-white/40 font-black uppercase tracking-widest"><Calendar size={14} className="text-primary/50" /><span>{t('joined')} {formatRegistrationDate(user.createdAt)}</span></div>
|
<div className="flex items-center gap-2 text-xs text-white/40 font-black uppercase tracking-widest"><Calendar size={14} className="text-primary/50" /><span>{t('joined')} {formatRegistrationDate(user.createdAt)}</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user