Files
gooseek/backend/webui/src/app/(main)/spaces/[id]/page.tsx
home 08bd41e75c feat: travel service with 2GIS routing, POI, hotels + finance providers + UI overhaul
- Add travel-svc microservice (Amadeus, TravelPayouts, 2GIS, OpenRouteService)
- Add travel orchestrator with parallel collectors (events, POI, hotels, flights)
- Add 2GIS road routing with transport cost calculation (car/bus/taxi)
- Add TravelMap (2GIS MapGL) and TravelWidgets components
- Add useTravelChat hook for streaming travel agent responses
- Add finance heatmap providers refactor
- Add SearXNG settings, API proxy routes, Docker compose updates
- Update Dockerfiles, config, types, and all UI pages for consistency

Made-with: Cursor
2026-03-01 21:58:32 +03:00

467 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import { motion, AnimatePresence } from 'framer-motion';
import {
ArrowLeft,
FolderOpen,
Plus,
Users,
MessageSquare,
Settings,
UserPlus,
Loader2,
Clock,
MoreHorizontal,
Trash2,
Crown,
Shield,
User,
Copy,
Check,
X,
Send,
} from 'lucide-react';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import * as Dialog from '@radix-ui/react-dialog';
import { fetchSpace, fetchSpaceMembers, fetchSpaceThreads, inviteToSpace, removeSpaceMember } from '@/lib/api';
import type { Space, SpaceMember, Thread } from '@/lib/types';
import { useAuth } from '@/lib/contexts/AuthContext';
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) return 'Сегодня';
if (days === 1) return 'Вчера';
if (days < 7) return `${days} дн. назад`;
return date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' });
}
function getRoleIcon(role: string) {
switch (role) {
case 'owner': return Crown;
case 'admin': return Shield;
default: return User;
}
}
function getRoleLabel(role: string) {
switch (role) {
case 'owner': return 'Владелец';
case 'admin': return 'Админ';
default: return 'Участник';
}
}
export default function SpaceDetailPage() {
const params = useParams();
const router = useRouter();
const { user } = useAuth();
const spaceId = params.id as string;
const [space, setSpace] = useState<Space | null>(null);
const [members, setMembers] = useState<SpaceMember[]>([]);
const [threads, setThreads] = useState<Thread[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [activeTab, setActiveTab] = useState<'threads' | 'members'>('threads');
const [showInviteModal, setShowInviteModal] = useState(false);
const [inviteEmail, setInviteEmail] = useState('');
const [inviteLoading, setInviteLoading] = useState(false);
const [inviteError, setInviteError] = useState('');
const [inviteSuccess, setInviteSuccess] = useState('');
const isOwner = space?.userId === user?.id;
const currentMember = members.find(m => m.userId === user?.id);
const isAdmin = isOwner || currentMember?.role === 'admin';
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const [spaceData, membersData, threadsData] = await Promise.all([
fetchSpace(spaceId),
fetchSpaceMembers(spaceId),
fetchSpaceThreads(spaceId),
]);
setSpace(spaceData);
setMembers(membersData);
setThreads(threadsData);
} catch (err) {
console.error('Failed to load space:', err);
} finally {
setIsLoading(false);
}
}, [spaceId]);
useEffect(() => {
loadData();
}, [loadData]);
const handleInvite = async (e: React.FormEvent) => {
e.preventDefault();
if (!inviteEmail.trim() || inviteLoading) return;
setInviteLoading(true);
setInviteError('');
setInviteSuccess('');
try {
await inviteToSpace(spaceId, inviteEmail.trim());
setInviteSuccess(`Приглашение отправлено на ${inviteEmail}`);
setInviteEmail('');
setTimeout(() => setShowInviteModal(false), 2000);
} catch (err) {
setInviteError(err instanceof Error ? err.message : 'Не удалось отправить приглашение');
} finally {
setInviteLoading(false);
}
};
const handleRemoveMember = async (memberId: string, userId: string) => {
if (!confirm('Удалить участника из пространства?')) return;
try {
await removeSpaceMember(spaceId, userId);
setMembers(prev => prev.filter(m => m.id !== memberId));
} catch (err) {
console.error('Failed to remove member:', err);
}
};
const startNewThread = () => {
router.push(`/?space=${spaceId}`);
};
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh]">
<Loader2 className="w-8 h-8 animate-spin loader-gradient" />
<p className="text-sm text-muted mt-4">Загрузка пространства...</p>
</div>
);
}
if (!space) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh]">
<FolderOpen className="w-12 h-12 text-muted mb-4" />
<h2 className="text-xl font-semibold text-primary mb-2">Пространство не найдено</h2>
<p className="text-secondary mb-6">Возможно, оно было удалено или у вас нет доступа</p>
<Link href="/spaces" className="btn-gradient px-5 py-2.5">
<span className="btn-gradient-text">К списку пространств</span>
</Link>
</div>
);
}
return (
<div className="h-full overflow-y-auto">
<div className="max-w-5xl mx-auto px-4 sm:px-6 py-6 sm:py-8">
{/* Header */}
<div className="flex items-start gap-4 mb-8">
<Link
href="/spaces"
className="w-10 h-10 flex items-center justify-center rounded-xl text-secondary hover:text-primary hover:bg-surface/50 transition-all flex-shrink-0"
>
<ArrowLeft className="w-5 h-5" />
</Link>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-primary mb-2">{space.name}</h1>
{space.description && (
<p className="text-secondary">{space.description}</p>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{isAdmin && (
<button
onClick={() => setShowInviteModal(true)}
className="flex items-center gap-2 px-4 py-2.5 text-sm btn-gradient"
>
<UserPlus className="w-4 h-4 btn-gradient-text" />
<span className="btn-gradient-text hidden sm:inline">Пригласить</span>
</button>
)}
<Link
href={`/spaces/${spaceId}/edit`}
className="p-2.5 rounded-xl text-secondary hover:text-primary hover:bg-surface/50 transition-all"
>
<Settings className="w-5 h-5" />
</Link>
</div>
</div>
{/* Stats */}
<div className="flex items-center gap-6 mt-4">
<div className="flex items-center gap-2 text-sm text-muted">
<Users className="w-4 h-4" />
<span>{members.length} участник{members.length === 1 ? '' : members.length < 5 ? 'а' : 'ов'}</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted">
<MessageSquare className="w-4 h-4" />
<span>{threads.length} тред{threads.length === 1 ? '' : threads.length < 5 ? 'а' : 'ов'}</span>
</div>
</div>
</div>
</div>
{/* Tabs */}
<div className="flex items-center gap-1 p-1 bg-surface/40 rounded-xl mb-6 w-fit">
<button
onClick={() => setActiveTab('threads')}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-all ${
activeTab === 'threads'
? 'bg-accent/20 text-accent'
: 'text-secondary hover:text-primary'
}`}
>
<span className="flex items-center gap-2">
<MessageSquare className="w-4 h-4" />
Треды
</span>
</button>
<button
onClick={() => setActiveTab('members')}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-all ${
activeTab === 'members'
? 'bg-accent/20 text-accent'
: 'text-secondary hover:text-primary'
}`}
>
<span className="flex items-center gap-2">
<Users className="w-4 h-4" />
Участники
</span>
</button>
</div>
{/* Content */}
<AnimatePresence mode="wait">
{activeTab === 'threads' ? (
<motion.div
key="threads"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
>
{/* New Thread Button */}
<button
onClick={startNewThread}
className="w-full p-4 mb-4 border-2 border-dashed border-border/50 rounded-xl text-secondary hover:text-primary hover:border-accent/30 hover:bg-accent/5 transition-all flex items-center justify-center gap-2"
>
<Plus className="w-5 h-5" />
<span>Начать новый тред</span>
</button>
{/* Threads List */}
{threads.length > 0 ? (
<div className="space-y-3">
{threads.map((thread, i) => (
<motion.div
key={thread.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.03 }}
>
<Link
href={`/thread/${thread.id}`}
className="block p-4 bg-elevated/40 border border-border/40 rounded-xl hover:bg-elevated/60 hover:border-border transition-all group"
>
<h3 className="font-medium text-primary group-hover:text-gradient transition-colors line-clamp-1 mb-2">
{thread.title || 'Без названия'}
</h3>
<div className="flex items-center gap-4 text-xs text-muted">
<span className="flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5" />
{formatDate(thread.updatedAt)}
</span>
<span>{thread.messages?.length || 0} сообщений</span>
</div>
</Link>
</motion.div>
))}
</div>
) : (
<div className="text-center py-12">
<MessageSquare className="w-12 h-12 mx-auto mb-4 text-muted" />
<p className="text-secondary mb-2">Пока нет тредов</p>
<p className="text-sm text-muted">Начните новый тред для обсуждения</p>
</div>
)}
</motion.div>
) : (
<motion.div
key="members"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
>
{/* Members List */}
<div className="space-y-2">
{members.map((member, i) => {
const RoleIcon = getRoleIcon(member.role);
const canRemove = isAdmin && member.userId !== user?.id && member.role !== 'owner';
return (
<motion.div
key={member.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.03 }}
className="flex items-center gap-4 p-4 bg-elevated/40 border border-border/40 rounded-xl"
>
<div className="w-10 h-10 rounded-full bg-accent/20 flex items-center justify-center flex-shrink-0">
{member.avatar ? (
<img src={member.avatar} alt="" className="w-full h-full rounded-full object-cover" />
) : (
<span className="text-sm font-semibold text-accent">
{(member.name || member.email || '?').charAt(0).toUpperCase()}
</span>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-primary truncate">
{member.name || member.email}
</span>
<span className={`flex items-center gap-1 text-xs px-2 py-0.5 rounded-full ${
member.role === 'owner'
? 'bg-amber-500/15 text-amber-600'
: member.role === 'admin'
? 'bg-accent/20 text-accent'
: 'bg-surface text-muted'
}`}>
<RoleIcon className="w-3 h-3" />
{getRoleLabel(member.role)}
</span>
</div>
{member.email && member.name && (
<p className="text-sm text-muted truncate">{member.email}</p>
)}
</div>
{canRemove && (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button className="p-2 hover:bg-surface/60 rounded-lg transition-all">
<MoreHorizontal className="w-4 h-4 text-secondary" />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="min-w-[140px] bg-surface/95 backdrop-blur-xl border border-border rounded-xl p-1.5 shadow-dropdown z-50"
sideOffset={5}
>
<DropdownMenu.Item
onClick={() => handleRemoveMember(member.id, member.userId)}
className="flex items-center gap-2 px-3 py-2.5 text-sm text-error rounded-lg cursor-pointer hover:bg-error/10 outline-none transition-colors"
>
<Trash2 className="w-4 h-4" />
Удалить
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
)}
</motion.div>
);
})}
</div>
{/* Invite Button */}
{isAdmin && (
<button
onClick={() => setShowInviteModal(true)}
className="w-full mt-4 p-4 border-2 border-dashed border-border/50 rounded-xl text-secondary hover:text-primary hover:border-accent/30 hover:bg-accent/5 transition-all flex items-center justify-center gap-2"
>
<UserPlus className="w-5 h-5" />
<span>Пригласить участника</span>
</button>
)}
</motion.div>
)}
</AnimatePresence>
{/* Invite Modal */}
<Dialog.Root open={showInviteModal} onOpenChange={setShowInviteModal}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-primary/30 backdrop-blur-sm z-50" />
<Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md bg-elevated border border-border rounded-2xl p-6 z-50 shadow-2xl">
<div className="flex items-center justify-between mb-6">
<Dialog.Title className="text-lg font-semibold text-primary">
Пригласить участника
</Dialog.Title>
<Dialog.Close className="p-2 text-muted hover:text-secondary rounded-lg hover:bg-surface/50 transition-colors">
<X className="w-5 h-5" />
</Dialog.Close>
</div>
<form onSubmit={handleInvite}>
<p className="text-sm text-secondary mb-4">
Введите email пользователя, которого хотите пригласить в пространство
</p>
{inviteError && (
<div className="mb-4 p-3 bg-error/10 border border-error/30 rounded-xl text-sm text-error">
{inviteError}
</div>
)}
{inviteSuccess && (
<div className="mb-4 p-3 bg-success/10 border border-success/30 rounded-xl text-sm text-success flex items-center gap-2">
<Check className="w-4 h-4" />
{inviteSuccess}
</div>
)}
<div className="relative mb-6">
<input
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder="email@example.com"
className="w-full px-4 py-3 bg-surface/50 border border-border rounded-xl text-primary placeholder:text-muted focus:outline-none input-gradient transition-colors"
autoFocus
/>
</div>
<div className="flex gap-3">
<Dialog.Close asChild>
<button
type="button"
className="flex-1 px-4 py-3 text-sm text-secondary hover:text-primary bg-surface/40 border border-border/50 rounded-xl transition-all"
>
Отмена
</button>
</Dialog.Close>
<button
type="submit"
disabled={!inviteEmail.trim() || inviteLoading}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 text-sm btn-gradient disabled:opacity-50"
>
{inviteLoading ? (
<Loader2 className="w-4 h-4 animate-spin btn-gradient-text" />
) : (
<Send className="w-4 h-4 btn-gradient-text" />
)}
<span className="btn-gradient-text">Отправить</span>
</button>
</div>
</form>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
</div>
</div>
);
}