Add for leave family

This commit is contained in:
lendry
2026-06-27 00:15:36 +03:00
parent 1a30e7e21c
commit 4b86c64cc4
17 changed files with 323 additions and 31 deletions

View File

@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import {
ArrowLeft,
ArrowRightLeft,
BarChart3,
CalendarDays,
Camera,
@@ -95,6 +96,7 @@ import {
setChatRoomPinned,
setChatRoomMuted,
toggleChatMessageReaction,
transferFamilyOwnership,
updateFamilyGroup,
uploadMediaObject,
voteChatPoll
@@ -281,7 +283,9 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
const [presenceMembers, setPresenceMembers] = useState<FamilyPresenceMember[]>([]);
const [deleteFamilyDialogOpen, setDeleteFamilyDialogOpen] = useState(false);
const [leaveFamilyDialogOpen, setLeaveFamilyDialogOpen] = useState(false);
const [transferOwnershipDialogOpen, setTransferOwnershipDialogOpen] = useState(false);
const [leavingFamily, setLeavingFamily] = useState(false);
const [transferringOwnership, setTransferringOwnership] = useState(false);
const [selectedNewOwnerUserId, setSelectedNewOwnerUserId] = useState<string | null>(null);
const [deletingFamily, setDeletingFamily] = useState(false);
const [deleteMessagesDialogOpen, setDeleteMessagesDialogOpen] = useState(false);
@@ -396,6 +400,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
);
const ownerMustTransfer = Boolean(isFamilyOwner && otherHumanMembers.length > 0);
const ownerLeaveDeletesFamily = Boolean(isFamilyOwner && otherHumanMembers.length === 0);
const mobileChatOpen = Boolean(activeRoomId);
const isChatCreator = activeRoom?.createdById === user?.id;
const canManageChatMembers = Boolean(isFamilyOwner || isChatCreator || activeRoomIsBot);
const canManageActiveBot = Boolean(
@@ -875,6 +880,30 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
setLeaveFamilyDialogOpen(true);
}
function openTransferOwnershipDialog() {
setSelectedNewOwnerUserId(otherHumanMembers[0]?.userId ?? null);
setTransferOwnershipDialogOpen(true);
}
async function confirmTransferOwnership() {
if (!token || !selectedNewOwnerUserId) {
showToast('Выберите нового главу семьи');
return;
}
setTransferringOwnership(true);
try {
const updated = await transferFamilyOwnership(groupId, selectedNewOwnerUserId, token);
setGroup(updated);
setTransferOwnershipDialogOpen(false);
setFamilyMembersOpen(false);
showToast('Управление семьёй передано');
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось передать управление семьёй') ?? 'Ошибка');
} finally {
setTransferringOwnership(false);
}
}
async function confirmLeaveFamily() {
if (!token) return;
if (ownerMustTransfer && !selectedNewOwnerUserId) {
@@ -1447,8 +1476,20 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
}
return (
<div className="flex h-[calc(100vh-120px)] min-h-[640px] overflow-hidden rounded-[28px] border border-[#eceef4] bg-[#eef2f8]">
<aside className="flex w-[300px] shrink-0 flex-col border-r border-[#e4e8ef] bg-white">
<div
className={cn(
'flex overflow-hidden bg-[#eef2f8]',
'h-[calc(100dvh-3.5rem-env(safe-area-inset-bottom)-4.5rem)] min-h-0 lg:h-[calc(100vh-120px)] lg:min-h-[640px]',
'rounded-none border-0 lg:rounded-[28px] lg:border lg:border-[#eceef4]'
)}
>
<aside
className={cn(
'flex shrink-0 flex-col border-r border-[#e4e8ef] bg-white',
'w-full lg:w-[300px]',
mobileChatOpen ? 'hidden lg:flex' : 'flex'
)}
>
<div className="border-b border-[#eceef4] p-4">
<button type="button" className="mb-3 flex items-center gap-2 text-sm text-[#667085]" onClick={() => router.push('/family')}>
<ArrowLeft className="h-4 w-4" />
@@ -1557,11 +1598,24 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
</div>
</aside>
<section className="flex min-w-0 flex-1 flex-col bg-[#e6ebf2]">
<section
className={cn(
'min-w-0 flex-1 flex-col bg-[#e6ebf2]',
mobileChatOpen ? 'flex' : 'hidden lg:flex'
)}
>
{activeRoom ? (
<>
<div className="flex items-center justify-between border-b border-[#dce3ec] bg-white px-4 py-3">
<div className="flex min-w-0 items-center gap-3">
<div className="flex items-center justify-between gap-2 border-b border-[#dce3ec] bg-white px-3 py-3 sm:px-4">
<div className="flex min-w-0 flex-1 items-center gap-2 sm:gap-3">
<button
type="button"
aria-label="Назад к списку чатов"
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl text-[#667085] transition hover:bg-[#f4f5f8] lg:hidden"
onClick={() => setActiveRoomId(null)}
>
<ArrowLeft className="h-5 w-5" />
</button>
{(activeRoom.type === 'GROUP' || activeRoom.type === 'GENERAL') ? (
<label className="group/room-avatar relative block shrink-0 cursor-pointer overflow-hidden rounded-full">
<ChatRoomAvatarDisplay room={activeRoom} viewerUserId={user?.id ?? ''} token={token} />
@@ -1623,13 +1677,13 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
</p>
</button>
</div>
<div className="flex items-center gap-2">
<div className="flex shrink-0 items-center gap-1 sm:gap-2">
{!activeRoomIsBot ? (
<>
<Button
size="sm"
variant="secondary"
className="rounded-xl"
className="h-9 w-9 rounded-xl p-0 sm:h-auto sm:w-auto sm:px-3"
aria-label="Поиск по сообщениям"
onClick={() => {
setChatToolsTab('search');
@@ -1641,7 +1695,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
<Button
size="sm"
variant="secondary"
className="rounded-xl"
className="hidden h-9 w-9 rounded-xl p-0 sm:flex sm:h-auto sm:w-auto sm:px-3"
aria-label="Перейти к дате"
onClick={() => {
setChatToolsTab('calendar');
@@ -2095,7 +2149,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
onDelete={() => void handleBulkDelete()}
/>
) : (
<div className="border-t border-[#dce3ec] bg-white px-3 py-3">
<div className="border-t border-[#dce3ec] bg-white px-2 py-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] sm:px-3 sm:py-3">
{editingMessageId ? (
<ChatMessageEditBanner
preview={draft.trim() || messages.find((item) => item.id === editingMessageId)?.content || undefined}
@@ -2126,7 +2180,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
<Paperclip className="h-4 w-4" />
</Button>
{!activeRoomIsE2E ? (
<Button size="sm" variant="ghost" className="h-10 w-10 rounded-xl p-0" onClick={() => setPollOpen(true)}>
<Button size="sm" variant="ghost" className="hidden h-10 w-10 rounded-xl p-0 sm:flex" onClick={() => setPollOpen(true)}>
<BarChart3 className="h-4 w-4" />
</Button>
) : null}
@@ -2373,6 +2427,19 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
<UserPlus className="mr-2 h-4 w-4" />
Пригласить в семью
</Button>
{isFamilyOwner && otherHumanMembers.length > 0 ? (
<Button
className="mt-2 w-full rounded-xl"
variant="secondary"
onClick={() => {
setFamilyMembersOpen(false);
openTransferOwnershipDialog();
}}
>
<ArrowRightLeft className="mr-2 h-4 w-4" />
Передать управление
</Button>
) : null}
<Button
className="mt-2 w-full rounded-xl"
variant="secondary"
@@ -2399,6 +2466,73 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
</DialogContent>
</Dialog>
<Dialog open={transferOwnershipDialogOpen} onOpenChange={setTransferOwnershipDialogOpen}>
<DialogContent className="max-h-[90dvh] overflow-y-auto rounded-[28px] sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>Передать управление семьёй</DialogTitle>
</DialogHeader>
<p className="text-sm leading-relaxed text-[#667085]">
Выберите участника, который станет новым главой семьи «{group?.name}». Вы останетесь участником и сможете
покинуть семью в любой момент.
</p>
<div className="mt-4 max-h-[280px] space-y-2 overflow-y-auto">
{otherHumanMembers.map((member) => {
const selected = selectedNewOwnerUserId === member.userId;
return (
<button
key={member.id}
type="button"
onClick={() => setSelectedNewOwnerUserId(member.userId)}
className={cn(
'flex w-full items-center gap-3 rounded-2xl px-3 py-3 text-left transition',
selected ? 'bg-[#3390ec]/10 ring-2 ring-[#3390ec]/40' : 'bg-[#f4f5f8] hover:bg-[#eceef4]'
)}
>
<AvatarWithPresence
userId={member.userId}
displayName={member.displayName}
hasAvatar={member.hasAvatar}
token={token}
isVerified={member.isVerified}
verificationIcon={member.verificationIcon}
className="h-10 w-10"
/>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{member.displayName}</p>
<p className="text-xs text-[#667085]">{familyMemberRoleLabel(member.role)}</p>
</div>
{selected ? <Check className="h-5 w-5 shrink-0 text-[#3390ec]" /> : null}
</button>
);
})}
</div>
<div className="mt-6 flex flex-col-reverse gap-3 sm:flex-row">
<Button
variant="secondary"
className="flex-1"
disabled={transferringOwnership}
onClick={() => setTransferOwnershipDialogOpen(false)}
>
Отмена
</Button>
<Button
className="flex-1"
disabled={transferringOwnership || !selectedNewOwnerUserId}
onClick={() => void confirmTransferOwnership()}
>
{transferringOwnership ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Передаём...
</>
) : (
'Передать управление'
)}
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={leaveFamilyDialogOpen} onOpenChange={setLeaveFamilyDialogOpen}>
<DialogContent className="rounded-[28px] sm:max-w-[480px]">
<DialogHeader>

View File

@@ -712,7 +712,7 @@ export function MiniFamilyChat() {
type="button"
onClick={() => (miniChatOpen ? closeMiniChat() : openMiniChat())}
className={cn(
'fixed bottom-5 right-5 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-[#3390ec] text-white shadow-lg transition hover:bg-[#2b7fd4] lg:bottom-6 lg:right-6',
'fixed bottom-[calc(4.5rem+env(safe-area-inset-bottom))] right-4 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-[#3390ec] text-white shadow-lg transition hover:bg-[#2b7fd4] lg:bottom-6 lg:right-6',
miniChatOpen && 'scale-95'
)}
aria-label={miniChatOpen ? 'Закрыть чат семьи' : 'Открыть чат семьи'}
@@ -721,7 +721,7 @@ export function MiniFamilyChat() {
</button>
{miniChatOpen ? (
<div className="fixed bottom-20 right-5 z-50 flex w-[min(92vw,380px)] flex-col overflow-hidden rounded-[24px] border border-[#eceef4] bg-white shadow-2xl lg:bottom-[5.5rem] lg:right-6">
<div className="fixed bottom-[calc(4.5rem+env(safe-area-inset-bottom)+3.5rem)] right-4 z-50 flex w-[min(92vw,380px)] flex-col overflow-hidden rounded-[24px] border border-[#eceef4] bg-white shadow-2xl lg:bottom-[5.5rem] lg:right-6">
<div className="flex items-center gap-2 border-b border-[#eceef4] px-4 py-3">
{view === 'chat' || view === 'bot' ? (
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={() => setView('picker')}>

View File

@@ -0,0 +1,46 @@
'use client';
import Link from 'next/link';
import { FileText, Heart, Home, LockKeyhole, ShieldCheck, UserRound } from 'lucide-react';
import { cn } from '@/lib/utils';
import { getAdminLandingPath } from '@/lib/admin-access';
import { useAuth } from './auth-provider';
const baseItems = [
{ href: '/', label: 'Главная', icon: Home },
{ href: '/data', label: 'Данные', icon: UserRound },
{ href: '/documents', label: 'Документы', icon: FileText },
{ href: '/family', label: 'Семья', icon: Heart },
{ href: '/security', label: 'Защита', icon: LockKeyhole }
];
export function MobileNav({ active }: { active: string }) {
const { user } = useAuth();
const items = user?.canAccessAdmin
? [...baseItems, { href: getAdminLandingPath(user), label: 'Админ', icon: ShieldCheck }]
: baseItems;
return (
<nav className="fixed inset-x-0 bottom-0 z-50 border-t border-[#eceef4] bg-white/95 px-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-2 backdrop-blur lg:hidden">
<div className="mx-auto flex max-w-lg items-stretch justify-around gap-1">
{items.map((item) => {
const isActive =
active === item.href || (item.href.startsWith('/admin') && active.startsWith('/admin')) || (item.href === '/family' && active.startsWith('/family'));
return (
<Link
key={item.href}
href={item.href}
className={cn(
'flex min-w-0 flex-1 flex-col items-center gap-1 rounded-xl px-1 py-1.5 text-[10px] font-medium transition',
isActive ? 'text-[#3390ec]' : 'text-[#667085]'
)}
>
<item.icon className={cn('h-5 w-5 shrink-0', isActive && 'stroke-[2.25]')} />
<span className="truncate">{item.label}</span>
</Link>
);
})}
</div>
</nav>
);
}

View File

@@ -26,7 +26,7 @@ export function SettingsFieldRow({ setting, onChange }: SettingsFieldRowProps) {
<p className="mt-1 text-sm text-[#667085]">{meta?.hint ?? setting.description ?? setting.key}</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<div className="flex w-full shrink-0 items-center gap-2 md:w-auto">
{type === 'boolean' ? (
<button
type="button"
@@ -41,7 +41,7 @@ export function SettingsFieldRow({ setting, onChange }: SettingsFieldRowProps) {
</button>
) : type === 'select' ? (
<select
className="h-10 min-w-[220px] rounded-xl border border-[#e4e7ec] bg-white px-3 text-sm outline-none focus:border-[#98a2b3]"
className="h-10 w-full min-w-0 rounded-xl border border-[#e4e7ec] bg-white px-3 text-sm outline-none focus:border-[#98a2b3] md:min-w-[220px] md:w-auto"
value={setting.value}
onChange={(event) => onChange(event.target.value)}
>
@@ -55,7 +55,7 @@ export function SettingsFieldRow({ setting, onChange }: SettingsFieldRowProps) {
<textarea
value={setting.value}
rows={3}
className="min-h-[88px] w-full min-w-[280px] resize-y rounded-xl border border-[#e4e7ec] bg-white px-3 py-2 text-sm outline-none focus:border-[#98a2b3] md:w-[360px]"
className="min-h-[88px] w-full min-w-0 resize-y rounded-xl border border-[#e4e7ec] bg-white px-3 py-2 text-sm outline-none focus:border-[#98a2b3] md:w-[360px]"
onChange={(event) => onChange(event.target.value)}
/>
) : (
@@ -63,7 +63,7 @@ export function SettingsFieldRow({ setting, onChange }: SettingsFieldRowProps) {
<Input
type={setting.isSecret ? 'password' : type === 'number' ? 'number' : 'text'}
value={setting.value}
className={setting.isSecret ? 'w-full min-w-[240px] bg-white md:w-[280px]' : 'w-full min-w-[180px] bg-white md:w-[220px]'}
className={setting.isSecret ? 'w-full min-w-0 bg-white md:w-[280px]' : 'w-full min-w-0 bg-white md:w-[220px]'}
onChange={(event) => onChange(event.target.value)}
/>
{meta?.unit ? <span className="text-sm text-[#667085]">{meta.unit}</span> : null}

View File

@@ -1,25 +1,61 @@
'use client';
import { Sidebar } from './sidebar';
import { MobileNav } from './mobile-nav';
import { UserMenu } from './user-menu';
import { NotificationBell } from '@/components/notifications/notification-bell';
import { EmojiSpriteProvider } from '@/components/chat/emoji-sprite-provider';
import { FamilyOverlayProvider } from '@/components/family/family-overlay-provider';
import { MiniFamilyChat } from '@/components/family/mini-family-chat';
import { BrandLogo } from '@/components/id/brand-logo';
import Link from 'next/link';
import { cn } from '@/lib/utils';
export function IdShell({ active, children, wide = false }: { active: string; children: React.ReactNode; wide?: boolean }) {
export function IdShell({
active,
children,
wide = false,
fullBleed = false
}: {
active: string;
children: React.ReactNode;
wide?: boolean;
fullBleed?: boolean;
}) {
return (
<FamilyOverlayProvider>
<EmojiSpriteProvider />
<div className="min-h-screen bg-white">
<div className="min-h-screen overflow-x-hidden 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">
<div className="fixed left-0 right-0 top-0 z-40 flex items-center justify-between border-b border-[#eceef4] bg-white/95 px-4 py-3 backdrop-blur lg:hidden">
<Link href="/" className="shrink-0">
<BrandLogo size="sm" variant="dark" />
</Link>
<div className="flex items-center gap-2">
<NotificationBell />
<UserMenu />
</div>
</div>
<div className="fixed right-4 top-4 z-40 hidden items-center gap-2 lg:right-8 lg:top-6 lg:flex">
<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
className={cn(
'pb-[calc(4.5rem+env(safe-area-inset-bottom))] pt-14 lg:pb-8 lg:pt-8 lg:pl-[220px] lg:pr-8',
fullBleed && 'px-0 pb-[calc(4.5rem+env(safe-area-inset-bottom))] pt-14 lg:px-8'
)}
>
<div
className={cn(
'mx-auto w-full min-w-0',
fullBleed ? 'max-w-none' : wide ? 'max-w-[920px] px-4 sm:px-5 lg:px-0' : 'max-w-[560px] px-4 sm:px-5 lg:px-0'
)}
>
{children}
</div>
</main>
<MobileNav active={active} />
<MiniFamilyChat />
</div>
</FamilyOverlayProvider>