fix and update

This commit is contained in:
lendry
2026-06-25 16:01:41 +03:00
parent ce58e6f4c1
commit 3880c68d59
40 changed files with 1715 additions and 215 deletions

View File

@@ -3,10 +3,11 @@
import { useRef, useState } from 'react';
import { Camera, Loader2 } from 'lucide-react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/id/toast-provider';
import { useAvatarUrl } from '@/hooks/use-avatar-url';
import { apiFetch, getApiErrorMessage, uploadMediaObject } from '@/lib/api';
import { VerificationBadge } from '@/components/id/verification-badge';
import { cn } from '@/lib/utils';
interface PresignedUploadResponse {
uploadUrl: string;
@@ -20,13 +21,21 @@ export function AvatarUpload({
displayName,
hasAvatar,
token,
onUpdated
onUpdated,
isVerified,
verificationIcon,
className = 'h-24 w-24',
badgeSize = 'md'
}: {
userId: string;
displayName?: string;
hasAvatar?: boolean;
token: string | null;
onUpdated: () => Promise<void>;
isVerified?: boolean;
verificationIcon?: string | null;
className?: string;
badgeSize?: 'xs' | 'sm' | 'md' | 'lg';
}) {
const inputRef = useRef<HTMLInputElement>(null);
const { showToast } = useToast();
@@ -73,24 +82,45 @@ export function AvatarUpload({
}
}
function openFilePicker() {
if (isUploading || !token) return;
inputRef.current?.click();
}
return (
<div className="relative inline-flex">
<Avatar className="h-24 w-24 border-4 border-white shadow-xl">
{avatarUrl ? <AvatarImage src={avatarUrl} alt={displayName ?? 'Аватар'} /> : null}
<AvatarFallback className="bg-[linear-gradient(135deg,#6f58ff,#ffd2a8)] text-2xl">{initials}</AvatarFallback>
</Avatar>
<input ref={inputRef} type="file" accept="image/jpeg,image/png,image/webp,image/gif" className="hidden" onChange={handleFileChange} />
<Button
<div className="relative inline-flex shrink-0">
<button
type="button"
size="sm"
variant="secondary"
className="absolute -bottom-2 left-1/2 -translate-x-1/2 rounded-full px-3 shadow"
className={cn('group relative rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#3390ec] focus-visible:ring-offset-2', className)}
disabled={isUploading || !token}
onClick={() => inputRef.current?.click()}
aria-label="Изменить фото профиля"
onClick={openFilePicker}
>
{isUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Camera className="h-4 w-4" />}
{isUploading ? 'Загрузка...' : 'Фото'}
</Button>
<Avatar className={cn('border-4 border-white shadow-xl', className)}>
{avatarUrl ? <AvatarImage src={avatarUrl} alt={displayName ?? 'Аватар'} /> : null}
<AvatarFallback className="bg-[linear-gradient(135deg,#6f58ff,#ffd2a8)] text-2xl">{initials}</AvatarFallback>
</Avatar>
<span
className={cn(
'absolute inset-0 flex items-center justify-center rounded-full transition',
isUploading ? 'bg-black/45 opacity-100' : 'bg-black/0 opacity-0 group-hover:bg-black/40 group-hover:opacity-100 group-focus-visible:bg-black/40 group-focus-visible:opacity-100'
)}
>
{isUploading ? (
<Loader2 className="h-5 w-5 animate-spin text-white" />
) : (
<Camera className="h-5 w-5 text-white" />
)}
</span>
</button>
{isVerified ? (
<VerificationBadge
verificationIcon={verificationIcon}
size={badgeSize}
className="pointer-events-none absolute top-0 right-0 z-20"
/>
) : null}
<input ref={inputRef} type="file" accept="image/jpeg,image/png,image/webp,image/gif" className="hidden" onChange={handleFileChange} />
</div>
);
}
@@ -100,21 +130,36 @@ export function AvatarDisplay({
displayName,
hasAvatar,
token,
className
className,
isVerified,
verificationIcon,
badgeSize = 'sm'
}: {
userId: string;
displayName?: string;
hasAvatar?: boolean;
token: string | null;
className?: string;
isVerified?: boolean;
verificationIcon?: string | null;
badgeSize?: 'xs' | 'sm' | 'md' | 'lg';
}) {
const { avatarUrl } = useAvatarUrl(userId, hasAvatar, token);
const initials = (displayName ?? '').slice(0, 2).toUpperCase() || 'ID';
return (
<Avatar className={className}>
{avatarUrl ? <AvatarImage src={avatarUrl} alt={displayName ?? 'Аватар'} /> : null}
<AvatarFallback className="bg-[linear-gradient(135deg,#6f58ff,#ffd2a8)]">{initials}</AvatarFallback>
</Avatar>
<div className="relative inline-flex shrink-0">
<Avatar className={className}>
{avatarUrl ? <AvatarImage src={avatarUrl} alt={displayName ?? 'Аватар'} /> : null}
<AvatarFallback className="bg-[linear-gradient(135deg,#6f58ff,#ffd2a8)]">{initials}</AvatarFallback>
</Avatar>
{isVerified ? (
<VerificationBadge
verificationIcon={verificationIcon}
size={badgeSize}
className="absolute -top-0.5 -right-0.5 z-20"
/>
) : null}
</div>
);
}

View File

@@ -3,18 +3,23 @@
import { Sidebar } from './sidebar';
import { UserMenu } from './user-menu';
import { NotificationBell } from '@/components/notifications/notification-bell';
import { FamilyOverlayProvider } from '@/components/family/family-overlay-provider';
import { MiniFamilyChat } from '@/components/family/mini-family-chat';
export function IdShell({ active, children, wide = false }: { active: string; children: React.ReactNode; wide?: boolean }) {
return (
<div className="min-h-screen bg-white">
<Sidebar active={active} />
<div className="fixed right-4 top-4 z-40 flex items-center gap-2 lg:right-8 lg:top-6">
<NotificationBell />
<UserMenu />
<FamilyOverlayProvider>
<div className="min-h-screen bg-white">
<Sidebar active={active} />
<div className="fixed right-4 top-4 z-40 flex items-center gap-2 lg:right-8 lg:top-6">
<NotificationBell />
<UserMenu />
</div>
<main className="px-5 py-8 lg:pl-[220px] lg:pr-8">
<div className={wide ? 'mx-auto max-w-[920px]' : 'mx-auto max-w-[560px]'}>{children}</div>
</main>
<MiniFamilyChat />
</div>
<main className="px-5 py-8 lg:pl-[170px] lg:pr-8">
<div className={wide ? 'mx-auto max-w-[920px]' : 'mx-auto max-w-[560px]'}>{children}</div>
</main>
</div>
</FamilyOverlayProvider>
);
}

View File

@@ -2,6 +2,7 @@
import Link from 'next/link';
import { FileText, Heart, Home, LockKeyhole, LogOut, ShieldCheck, UserRound } from 'lucide-react';
import { FamilySidebarPanel } from '@/components/family/family-sidebar-panel';
import { BrandLogo } from '@/components/id/brand-logo';
import { cn } from '@/lib/utils';
import { getAdminLandingPath } from '@/lib/admin-access';
@@ -22,8 +23,8 @@ export function Sidebar({ active }: { active: string }) {
: baseItems;
return (
<aside className="fixed left-0 top-0 hidden h-screen w-[150px] flex-col justify-between border-r border-transparent bg-white px-3 py-7 text-[13px] lg:flex">
<div>
<aside className="fixed left-0 top-0 hidden h-screen w-[200px] flex-col justify-between border-r border-[#eceef4] bg-white px-3 py-7 text-[13px] lg:flex">
<div className="flex min-h-0 flex-1 flex-col">
<Link href="/" className="mb-7 block">
<BrandLogo size="lg" variant="dark" />
</Link>
@@ -42,6 +43,9 @@ export function Sidebar({ active }: { active: string }) {
</Link>
))}
</nav>
<div className="min-h-0 flex-1 overflow-hidden">
<FamilySidebarPanel />
</div>
</div>
<div className="space-y-2 text-[11px] text-[#667085]">
{user ? <p className="truncate font-medium text-[#1f2430]">{user.displayName}</p> : null}

View File

@@ -0,0 +1,54 @@
'use client';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { VerificationBadge } from '@/components/id/verification-badge';
import { useAvatarUrl } from '@/hooks/use-avatar-url';
import { cn } from '@/lib/utils';
export function UserAvatar({
userId,
displayName,
hasAvatar,
token,
isVerified,
verificationIcon,
className,
badgeSize = 'sm',
fallbackClassName
}: {
userId: string;
displayName?: string;
hasAvatar?: boolean;
token: string | null;
isVerified?: boolean;
verificationIcon?: string | null;
className?: string;
badgeSize?: 'xs' | 'sm' | 'md' | 'lg';
fallbackClassName?: string;
}) {
const { avatarUrl } = useAvatarUrl(userId, hasAvatar, token);
const initials = (displayName ?? '')
.split(' ')
.map((part) => part[0])
.join('')
.slice(0, 2)
.toUpperCase() || 'ID';
return (
<div className="relative inline-flex shrink-0">
<Avatar className={className}>
{avatarUrl ? <AvatarImage src={avatarUrl} alt={displayName ?? 'Аватар'} /> : null}
<AvatarFallback className={cn('bg-[linear-gradient(135deg,#6f58ff,#ffd2a8)] text-xs font-semibold', fallbackClassName)}>
{initials.slice(0, 2)}
</AvatarFallback>
</Avatar>
{isVerified ? (
<VerificationBadge
verificationIcon={verificationIcon}
size={badgeSize}
className="absolute -top-0.5 -right-0.5 z-20"
/>
) : null}
</div>
);
}

View File

@@ -13,10 +13,9 @@ import {
UserRound
} from 'lucide-react';
import { AdminBadge } from '@/components/id/admin-badge';
import { UserAvatar } from '@/components/id/user-avatar';
import { useAuth } from '@/components/id/auth-provider';
import { useAvatarUrl } from '@/hooks/use-avatar-url';
import { getAdminLandingPath } from '@/lib/admin-access';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
@@ -38,17 +37,10 @@ const menuItems = [
export function UserMenu({ className }: { className?: string }) {
const router = useRouter();
const { user, token, logout } = useAuth();
const { avatarUrl } = useAvatarUrl(user?.id, user?.hasAvatar, token);
if (!user) return null;
const contactLine = [maskPhone(user.phone), user.username ?? user.email].filter(Boolean).join(' · ');
const initials = user.displayName
.split(' ')
.map((part) => part[0])
.join('')
.slice(0, 2)
.toUpperCase();
return (
<Popover>
@@ -58,19 +50,31 @@ export function UserMenu({ className }: { className?: string }) {
className={cn('flex items-center gap-2 rounded-full border border-[#eceef4] bg-white py-1 pl-1 pr-3 shadow-sm transition hover:bg-[#fafbfd]', className)}
aria-label="Меню пользователя"
>
<Avatar className="h-9 w-9">
{avatarUrl ? <AvatarImage src={avatarUrl} alt={user.displayName} /> : null}
<AvatarFallback className="text-xs font-semibold">{initials}</AvatarFallback>
</Avatar>
<UserAvatar
userId={user.id}
displayName={user.displayName}
hasAvatar={user.hasAvatar}
token={token}
isVerified={user.isVerified}
verificationIcon={user.verificationIcon}
className="h-9 w-9"
badgeSize="xs"
/>
<span className="hidden max-w-[120px] truncate text-sm font-medium md:inline">{user.displayName}</span>
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-[min(92vw,360px)] rounded-[28px] border-[#eceef4] p-0 shadow-2xl">
<div className="border-b border-[#eceef4] px-5 pb-5 pt-5 text-center">
<Avatar className="mx-auto h-20 w-20">
{avatarUrl ? <AvatarImage src={avatarUrl} alt={user.displayName} /> : null}
<AvatarFallback className="text-lg font-semibold">{initials}</AvatarFallback>
</Avatar>
<UserAvatar
userId={user.id}
displayName={user.displayName}
hasAvatar={user.hasAvatar}
token={token}
isVerified={user.isVerified}
verificationIcon={user.verificationIcon}
className="mx-auto h-20 w-20"
badgeSize="md"
/>
<div className="mt-3 flex items-center justify-center gap-2">
<p className="text-lg font-semibold">{user.displayName}</p>
<AdminBadge user={user} />

View File

@@ -0,0 +1,42 @@
'use client';
import { cn } from '@/lib/utils';
import { VerificationIconGlyph } from '@/lib/verification-icons';
const sizeClasses = {
xs: 'h-3.5 w-3.5 border',
sm: 'h-4 w-4 border',
md: 'h-5 w-5 border-2',
lg: 'h-6 w-6 border-2'
} as const;
const iconSizeClasses = {
xs: 'h-2 w-2',
sm: 'h-2.5 w-2.5',
md: 'h-3 w-3',
lg: 'h-3.5 w-3.5'
} as const;
export function VerificationBadge({
verificationIcon,
size = 'sm',
className
}: {
verificationIcon?: string | null;
size?: keyof typeof sizeClasses;
className?: string;
}) {
return (
<span
className={cn(
'inline-flex items-center justify-center rounded-full border-white bg-[#3390ec] text-white shadow-sm',
sizeClasses[size],
className
)}
title="Верифицированный пользователь"
aria-label="Верифицированный пользователь"
>
<VerificationIconGlyph slug={verificationIcon} className={iconSizeClasses[size]} />
</span>
);
}