feat: CI/CD pipeline + Learning/Medicine/Travel services
- Add Gitea Actions workflow for automated build & deploy - Add K8s manifests: webui, travel-svc, medicine-svc, sandbox-svc - Update kustomization for localhost:5000 registry - Add ingress for gooseek.ru and api.gooseek.ru - Learning cabinet with onboarding, courses, sandbox integration - Medicine service with symptom analysis and doctor matching - Travel service with itinerary planning - Server setup scripts (NVIDIA/CUDA, K3s, Gitea runner) Made-with: Cursor
This commit is contained in:
@@ -100,7 +100,7 @@ function PostModal({ post, onClose, onSave }: PostModalProps) {
|
||||
<label className="block text-sm text-secondary mb-1">Статус</label>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
onChange={(e) => setStatus(e.target.value as 'draft' | 'published' | 'archived')}
|
||||
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="draft">Черновик</option>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { usePathname } from 'next/navigation';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Menu, X } from 'lucide-react';
|
||||
import { Sidebar } from '@/components/Sidebar';
|
||||
import { ThemeToggle } from '@/components/ThemeToggle';
|
||||
|
||||
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
@@ -41,30 +42,39 @@ export default function MainLayout({ children }: { children: React.ReactNode })
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
<span className="font-black italic text-primary tracking-tight text-2xl">GooSeek</span>
|
||||
<div className="w-10" />
|
||||
<div className="w-10 flex items-center justify-end">
|
||||
<ThemeToggle variant="icon" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Desktop Sidebar */}
|
||||
{!isMobile && <Sidebar />}
|
||||
{!isMobile && (
|
||||
<div className="relative z-30">
|
||||
<Sidebar />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile Sidebar Overlay */}
|
||||
<AnimatePresence>
|
||||
<AnimatePresence mode="wait">
|
||||
{isMobile && sidebarOpen && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className="fixed inset-0 z-40 bg-base/80 backdrop-blur-sm"
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ x: -240 }}
|
||||
initial={{ x: -260 }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: -240 }}
|
||||
exit={{ x: -260 }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||
className="fixed left-0 top-0 bottom-0 z-50 w-[240px]"
|
||||
className="fixed left-0 top-0 bottom-0 z-50 w-[260px]"
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
>
|
||||
<Sidebar onClose={() => setSidebarOpen(false)} />
|
||||
</motion.div>
|
||||
|
||||
263
backend/webui/src/app/(main)/learning/courses/[slug]/page.tsx
Normal file
263
backend/webui/src/app/(main)/learning/courses/[slug]/page.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
ArrowLeft,
|
||||
BookOpen,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Loader2,
|
||||
Play,
|
||||
Target,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { enrollInCourse, fetchLearningCourse } from '@/lib/api';
|
||||
import type { CourseModule, LearningCourse } from '@/lib/types';
|
||||
|
||||
const difficultyLabels: Record<string, string> = {
|
||||
beginner: 'Начинающий',
|
||||
intermediate: 'Средний',
|
||||
advanced: 'Продвинутый',
|
||||
expert: 'Эксперт',
|
||||
};
|
||||
|
||||
export default function LearningCourseLandingPage() {
|
||||
const params = useParams<{ slug: string }>();
|
||||
const router = useRouter();
|
||||
const slug = params?.slug || '';
|
||||
|
||||
const [course, setCourse] = useState<LearningCourse | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isEnrolling, setIsEnrolling] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const modules = useMemo<CourseModule[]>(() => {
|
||||
if (!course?.baseOutline?.modules || !Array.isArray(course.baseOutline.modules)) {
|
||||
return [];
|
||||
}
|
||||
return course.baseOutline.modules;
|
||||
}, [course]);
|
||||
|
||||
const loadCourse = useCallback(async () => {
|
||||
if (!slug) return;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await fetchLearningCourse(slug);
|
||||
if (!data) {
|
||||
setError('Курс не найден');
|
||||
setCourse(null);
|
||||
return;
|
||||
}
|
||||
setCourse(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Не удалось загрузить курс');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [slug]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadCourse();
|
||||
}, [loadCourse]);
|
||||
|
||||
const handleEnroll = useCallback(async () => {
|
||||
if (!course) return;
|
||||
setIsEnrolling(true);
|
||||
setError(null);
|
||||
try {
|
||||
await enrollInCourse(course.id);
|
||||
router.push('/learning');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Не удалось записаться на курс');
|
||||
} finally {
|
||||
setIsEnrolling(false);
|
||||
}
|
||||
}, [course, router]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="flex items-center gap-2 text-muted text-sm">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Загрузка курса...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!course) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center px-4">
|
||||
<div className="max-w-md w-full p-4 rounded-2xl border border-border/40 bg-surface/40 text-center">
|
||||
<p className="text-sm text-primary font-medium">Курс недоступен</p>
|
||||
{error && <p className="text-xs text-muted mt-1">{error}</p>}
|
||||
<button
|
||||
onClick={() => router.push('/learning')}
|
||||
className="mt-4 inline-flex items-center gap-1.5 px-3 py-2 text-xs btn-gradient rounded-lg"
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5 btn-gradient-text" />
|
||||
<span className="btn-gradient-text">Назад к обучению</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto bg-gradient-main">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-6 space-y-5">
|
||||
<button
|
||||
onClick={() => router.push('/learning')}
|
||||
className="inline-flex items-center gap-2 text-xs text-secondary hover:text-primary transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
Назад к обучению
|
||||
</button>
|
||||
|
||||
<motion.section
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="p-5 rounded-2xl border border-border/40 bg-surface/40"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[10px] px-2 py-0.5 rounded border border-border/40 text-muted">
|
||||
{difficultyLabels[course.difficulty] || course.difficulty}
|
||||
</span>
|
||||
<span className="text-[10px] px-2 py-0.5 rounded border border-border/40 text-muted">
|
||||
{course.category}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 text-[10px] text-faint">
|
||||
<Clock className="w-3 h-3" />
|
||||
{course.durationHours}ч
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 text-[10px] text-faint">
|
||||
<Users className="w-3 h-3" />
|
||||
{course.enrolledCount} записей
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="mt-3 text-2xl sm:text-3xl font-bold text-primary">
|
||||
{course.landing?.hero_title || course.title}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-secondary max-w-3xl">
|
||||
{course.landing?.hero_subtitle || course.shortDescription}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onClick={handleEnroll}
|
||||
disabled={isEnrolling}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs btn-gradient rounded-lg disabled:opacity-70"
|
||||
>
|
||||
{isEnrolling ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin btn-gradient-text" />
|
||||
) : (
|
||||
<Play className="w-3.5 h-3.5 btn-gradient-text" />
|
||||
)}
|
||||
<span className="btn-gradient-text">
|
||||
{isEnrolling ? 'Записываем...' : 'Записаться и начать'}
|
||||
</span>
|
||||
</button>
|
||||
{course.landing?.salary_range && (
|
||||
<span className="text-xs px-2.5 py-1 rounded-lg bg-success/10 text-success border border-success/25">
|
||||
Доход после курса: {course.landing.salary_range}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.section>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 rounded-xl border border-error/30 bg-error/10 text-sm text-error">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<section className="lg:col-span-2 p-4 rounded-2xl border border-border/40 bg-surface/40">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="w-4 h-4 text-accent" />
|
||||
<h2 className="text-sm font-medium text-primary">Программа курса</h2>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{modules.length > 0 ? (
|
||||
modules.map((moduleItem, index) => (
|
||||
<div key={`${moduleItem.title}-${index}`} className="p-3 rounded-xl border border-border/40 bg-elevated/30">
|
||||
<p className="text-xs text-muted">Модуль {index + 1}</p>
|
||||
<p className="text-sm font-medium text-primary mt-0.5">{moduleItem.title}</p>
|
||||
<p className="text-xs text-secondary mt-1">{moduleItem.description}</p>
|
||||
{moduleItem.practice_focus && (
|
||||
<div className="mt-2 text-[11px] text-faint inline-flex items-center gap-1.5">
|
||||
<Target className="w-3 h-3" />
|
||||
Практика: {moduleItem.practice_focus}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="p-3 rounded-xl border border-border/40 bg-elevated/20 text-xs text-muted">
|
||||
Программа появится после публикации модулей.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="p-4 rounded-2xl border border-border/40 bg-surface/40 space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-primary">Для кого курс</h3>
|
||||
<p className="text-xs text-secondary mt-1">
|
||||
{course.landing?.target_audience || 'Разработчики и специалисты, которым нужен практический рост.'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-primary">Что получите</h3>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{(course.landing?.outcomes || []).slice(0, 6).map((item, index) => (
|
||||
<li key={index} className="text-xs text-secondary flex gap-2">
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-success mt-0.5" />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-primary">Порог входа</h3>
|
||||
<p className="text-xs text-secondary mt-1">
|
||||
{course.landing?.prerequisites || 'Базовые знания программирования и готовность к практике.'}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{(course.landing?.benefits || []).length > 0 && (
|
||||
<section className="p-4 rounded-2xl border border-border/40 bg-surface/40">
|
||||
<h2 className="text-sm font-medium text-primary">Преимущества</h2>
|
||||
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{(course.landing?.benefits || []).map((benefit, index) => (
|
||||
<div key={index} className="p-3 rounded-xl border border-border/40 bg-elevated/30 text-xs text-secondary">
|
||||
{benefit}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{(course.landing?.faq || []).length > 0 && (
|
||||
<section className="p-4 rounded-2xl border border-border/40 bg-surface/40">
|
||||
<h2 className="text-sm font-medium text-primary">FAQ</h2>
|
||||
<div className="mt-3 space-y-2">
|
||||
{(course.landing?.faq || []).map((item, index) => (
|
||||
<div key={index} className="p-3 rounded-xl border border-border/40 bg-elevated/30">
|
||||
<p className="text-xs font-medium text-primary">{item.question}</p>
|
||||
<p className="text-xs text-secondary mt-1">{item.answer}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, Loader2, Target, BookOpen, Brain, Sparkles, Code2, CheckCircle2 } from 'lucide-react';
|
||||
import { createLesson } from '@/lib/api';
|
||||
|
||||
const difficulties = [
|
||||
{ value: 'beginner', label: 'Начинающий', icon: Target },
|
||||
{ value: 'intermediate', label: 'Средний', icon: BookOpen },
|
||||
{ value: 'advanced', label: 'Продвинутый', icon: Brain },
|
||||
{ value: 'expert', label: 'Эксперт', icon: Sparkles },
|
||||
];
|
||||
|
||||
const modes = [
|
||||
{ value: 'explain', label: 'Объяснение' },
|
||||
{ value: 'guided', label: 'С наставником' },
|
||||
{ value: 'interactive', label: 'Интерактив' },
|
||||
{ value: 'practice', label: 'Практика' },
|
||||
{ value: 'quiz', label: 'Тест' },
|
||||
];
|
||||
|
||||
export default function NewLessonPage() {
|
||||
const router = useRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
topic: '',
|
||||
difficulty: 'beginner',
|
||||
mode: 'explain',
|
||||
includeCode: true,
|
||||
includeQuiz: true,
|
||||
});
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formData.topic.trim() || isSubmitting) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await createLesson({
|
||||
topic: formData.topic.trim(),
|
||||
difficulty: formData.difficulty,
|
||||
mode: formData.mode,
|
||||
includeCode: formData.includeCode,
|
||||
includeQuiz: formData.includeQuiz,
|
||||
locale: 'ru',
|
||||
});
|
||||
router.push('/learning');
|
||||
} catch (err) {
|
||||
console.error('Failed to create lesson:', err);
|
||||
setError('Не удалось создать урок. Попробуйте позже.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="max-w-lg mx-auto px-4 sm:px-6 py-6 sm:py-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4 mb-6 sm:mb-8">
|
||||
<Link
|
||||
href="/learning"
|
||||
className="w-10 h-10 flex items-center justify-center rounded-xl text-secondary hover:text-primary hover:bg-surface/50 transition-all"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-semibold text-primary">Новый урок</h1>
|
||||
<p className="text-sm text-secondary mt-0.5 hidden sm:block">Создайте интерактивный урок с AI</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-error/10 border border-error/30 rounded-xl text-sm text-error">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleCreate} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary mb-2">
|
||||
Тема урока <span className="text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
value={formData.topic}
|
||||
onChange={(e) => setFormData((f) => ({ ...f, topic: e.target.value }))}
|
||||
placeholder="Например: Основы Python"
|
||||
className="w-full px-4 py-3 text-sm bg-elevated/60 border border-border rounded-xl text-primary placeholder:text-muted focus:outline-none input-gradient transition-colors"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary mb-3">
|
||||
Уровень сложности
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{difficulties.map((d) => (
|
||||
<button
|
||||
key={d.value}
|
||||
type="button"
|
||||
onClick={() => setFormData((f) => ({ ...f, difficulty: d.value }))}
|
||||
className={`flex items-center gap-3 p-3 rounded-xl border transition-all ${
|
||||
formData.difficulty === d.value
|
||||
? 'active-gradient text-primary'
|
||||
: 'bg-elevated/40 border-border/50 text-muted hover:border-border hover:text-secondary'
|
||||
}`}
|
||||
>
|
||||
<d.icon className={`w-4 h-4 ${formData.difficulty === d.value ? 'text-gradient' : ''}`} />
|
||||
<span className="text-sm">{d.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary mb-2">
|
||||
Режим обучения
|
||||
</label>
|
||||
<select
|
||||
value={formData.mode}
|
||||
onChange={(e) => setFormData((f) => ({ ...f, mode: e.target.value }))}
|
||||
className="w-full px-4 py-3 text-sm bg-elevated/60 border border-border rounded-xl text-primary focus:outline-none input-gradient transition-colors"
|
||||
>
|
||||
{modes.map((m) => (
|
||||
<option key={m.value} value={m.value}>
|
||||
{m.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary mb-3">
|
||||
Дополнительные материалы
|
||||
</label>
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<label
|
||||
className={`flex-1 flex items-center gap-3 p-4 rounded-xl border cursor-pointer transition-all ${
|
||||
formData.includeCode
|
||||
? 'active-gradient text-primary'
|
||||
: 'bg-elevated/40 border-border/50 text-muted hover:border-border'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.includeCode}
|
||||
onChange={(e) => setFormData((f) => ({ ...f, includeCode: e.target.checked }))}
|
||||
className="sr-only"
|
||||
/>
|
||||
<Code2 className={`w-5 h-5 ${formData.includeCode ? 'text-gradient' : ''}`} />
|
||||
<span className="text-sm">Примеры кода</span>
|
||||
</label>
|
||||
<label
|
||||
className={`flex-1 flex items-center gap-3 p-4 rounded-xl border cursor-pointer transition-all ${
|
||||
formData.includeQuiz
|
||||
? 'active-gradient text-primary'
|
||||
: 'bg-elevated/40 border-border/50 text-muted hover:border-border'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.includeQuiz}
|
||||
onChange={(e) => setFormData((f) => ({ ...f, includeQuiz: e.target.checked }))}
|
||||
className="sr-only"
|
||||
/>
|
||||
<CheckCircle2 className={`w-5 h-5 ${formData.includeQuiz ? 'text-gradient' : ''}`} />
|
||||
<span className="text-sm">Тесты</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col-reverse sm:flex-row gap-3 pt-4">
|
||||
<Link
|
||||
href="/learning"
|
||||
className="flex-1 px-4 py-3 text-sm text-center text-secondary hover:text-primary bg-surface/40 border border-border/50 rounded-xl transition-all"
|
||||
>
|
||||
Отмена
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!formData.topic.trim() || isSubmitting}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 text-sm btn-gradient disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting && <Loader2 className="w-4 h-4 animate-spin btn-gradient-text" />}
|
||||
<span className="btn-gradient-text">Создать урок</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,298 +1,283 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
HeartPulse,
|
||||
Search,
|
||||
Pill,
|
||||
Stethoscope,
|
||||
FileText,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
Activity,
|
||||
Thermometer,
|
||||
Brain,
|
||||
Eye,
|
||||
Bone,
|
||||
Wind,
|
||||
Droplet,
|
||||
Shield,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { AlertTriangle, ArrowUp, HeartPulse, Loader2, MapPin, Plus, X } from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { useMedicineChat, type MedicineChatMessage, type MedicineWidget } from '@/lib/hooks/useMedicineChat';
|
||||
import { MedicineWidgetTabs } from '@/components/MedicineWidgetTabs';
|
||||
|
||||
interface Symptom {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: React.ElementType;
|
||||
category: string;
|
||||
}
|
||||
|
||||
interface Article {
|
||||
id: string;
|
||||
title: string;
|
||||
category: string;
|
||||
readTime: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const commonSymptoms: Symptom[] = [
|
||||
{ id: '1', name: 'Головная боль', icon: Brain, category: 'Неврология' },
|
||||
{ id: '2', name: 'Температура', icon: Thermometer, category: 'Общие' },
|
||||
{ id: '3', name: 'Боль в горле', icon: Wind, category: 'ЛОР' },
|
||||
{ id: '4', name: 'Боль в спине', icon: Bone, category: 'Ортопедия' },
|
||||
{ id: '5', name: 'Проблемы со зрением', icon: Eye, category: 'Офтальмология' },
|
||||
{ id: '6', name: 'Давление', icon: Activity, category: 'Кардиология' },
|
||||
{ id: '7', name: 'Аллергия', icon: Droplet, category: 'Аллергология' },
|
||||
{ id: '8', name: 'Усталость', icon: HeartPulse, category: 'Общие' },
|
||||
const quickPrompts = [
|
||||
{ icon: '🤒', text: 'Температура и слабость', query: 'Температура 38.2, слабость, ломота в теле второй день' },
|
||||
{ icon: '🫀', text: 'Боль в груди', query: 'Периодическая давящая боль в груди и одышка при нагрузке' },
|
||||
{ icon: '🧠', text: 'Головная боль', query: 'Сильные головные боли вечером, иногда тошнота, светобоязнь' },
|
||||
{ icon: '🤧', text: 'Кашель/горло', query: 'Кашель, боль в горле, насморк, температура 37.5' },
|
||||
];
|
||||
|
||||
const healthArticles: Article[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Как укрепить иммунитет зимой',
|
||||
category: 'Профилактика',
|
||||
readTime: '5 мин',
|
||||
icon: '🛡️',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Правильное питание для сердца',
|
||||
category: 'Кардиология',
|
||||
readTime: '7 мин',
|
||||
icon: '❤️',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Упражнения для здоровой спины',
|
||||
category: 'Ортопедия',
|
||||
readTime: '6 мин',
|
||||
icon: '🏃',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'Как справиться со стрессом',
|
||||
category: 'Психология',
|
||||
readTime: '8 мин',
|
||||
icon: '🧘',
|
||||
},
|
||||
];
|
||||
|
||||
const quickServices = [
|
||||
{ icon: Stethoscope, label: 'Найти врача', color: 'bg-blue-500/10 text-blue-600' },
|
||||
{ icon: Pill, label: 'Справочник лекарств', color: 'bg-green-500/10 text-green-600' },
|
||||
{ icon: FileText, label: 'Анализы', color: 'bg-purple-500/10 text-purple-600' },
|
||||
{ icon: Sparkles, label: 'AI Консультант', color: 'active-gradient text-gradient' },
|
||||
];
|
||||
|
||||
function SymptomButton({ symptom, onClick }: { symptom: Symptom; onClick: () => void }) {
|
||||
function AssistantMessage({ message }: { message: MedicineChatMessage }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-surface/50 border border-border/40 rounded-lg hover:border-border hover:bg-surface/70 transition-all text-left"
|
||||
>
|
||||
<symptom.icon className="w-4 h-4 text-muted flex-shrink-0" />
|
||||
<span className="text-sm text-secondary truncate">{symptom.name}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ArticleCard({ article, delay }: { article: Article; delay: number }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay }}
|
||||
className="flex items-center gap-3 p-3 bg-elevated/40 border border-border/40 rounded-xl hover:border-border transition-all cursor-pointer group"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-xl bg-surface/60 flex items-center justify-center text-xl flex-shrink-0">
|
||||
{article.icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-medium text-primary group-hover:text-gradient transition-colors truncate">
|
||||
{article.title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-xs text-muted">
|
||||
<span>{article.category}</span>
|
||||
<span>•</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{article.readTime}
|
||||
</span>
|
||||
<div className="max-w-full w-full">
|
||||
{message.content && (
|
||||
<div className="prose prose-sm prose-invert max-w-none text-ui">
|
||||
<ReactMarkdown>{message.content}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
{message.isStreaming && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin text-accent" />
|
||||
<span className="text-xs text-muted">Анализ симптомов и подбор специалистов...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MedicinePage() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [aiResponse, setAiResponse] = useState<string | null>(null);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [city, setCity] = useState('Москва');
|
||||
const [showPanel, setShowPanel] = useState(true);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleAIConsult = useCallback(async () => {
|
||||
if (!searchQuery.trim()) return;
|
||||
setIsLoading(true);
|
||||
setAiResponse(null);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
setAiResponse(
|
||||
'На основе описанных симптомов рекомендую обратиться к терапевту для первичного осмотра. ' +
|
||||
'Это может быть связано с несколькими причинами, которые требуют диагностики. ' +
|
||||
'До визита к врачу: пейте больше жидкости, отдыхайте, избегайте переохлаждения.'
|
||||
);
|
||||
setIsLoading(false);
|
||||
}, [searchQuery]);
|
||||
const { messages, isLoading, sendMessage, stopGeneration, clearChat } = useMedicineChat();
|
||||
const hasMessages = messages.length > 0;
|
||||
|
||||
const handleSymptomClick = (symptomName: string) => {
|
||||
setSearchQuery(symptomName);
|
||||
const allWidgets = useMemo((): MedicineWidget[] => {
|
||||
const dedup = new Map<string, MedicineWidget>();
|
||||
for (const msg of messages) {
|
||||
if (msg.role !== 'assistant') continue;
|
||||
for (const w of msg.widgets) {
|
||||
if (w.type.startsWith('medicine_')) dedup.set(w.type, w);
|
||||
}
|
||||
}
|
||||
return Array.from(dedup.values());
|
||||
}, [messages]);
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
if (!inputValue.trim() || isLoading) return;
|
||||
sendMessage(inputValue, city);
|
||||
setInputValue('');
|
||||
if (textareaRef.current) textareaRef.current.style.height = 'auto';
|
||||
}, [city, inputValue, isLoading, sendMessage]);
|
||||
|
||||
const handleInput = () => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 120)}px`;
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-6 sm:py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<h1 className="text-xl sm:text-2xl font-semibold text-primary mb-1 sm:mb-2">Медицина</h1>
|
||||
<p className="text-sm text-secondary">AI-помощник по здоровью и медицине</p>
|
||||
</div>
|
||||
|
||||
{/* Disclaimer */}
|
||||
<div className="flex items-start gap-3 p-4 bg-amber-500/5 border border-amber-500/20 rounded-xl mb-6">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-amber-600 mb-1">Важно</p>
|
||||
<p className="text-xs text-muted">
|
||||
Информация носит справочный характер и не заменяет консультацию врача.
|
||||
При серьёзных симптомах обратитесь к специалисту.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Search */}
|
||||
<div className="bg-elevated/40 border border-border/40 rounded-xl p-4 mb-6">
|
||||
<div className="relative mb-4">
|
||||
<Sparkles className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gradient" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleAIConsult()}
|
||||
placeholder="Опишите симптомы или задайте вопрос о здоровье..."
|
||||
className="w-full h-12 pl-12 pr-4 bg-surface/50 border border-border/50 rounded-xl text-sm text-primary placeholder:text-muted focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleAIConsult}
|
||||
disabled={isLoading || !searchQuery.trim()}
|
||||
className="w-full h-11 flex items-center justify-center gap-2 active-gradient text-gradient font-medium text-sm rounded-xl disabled:opacity-50 disabled:cursor-not-allowed transition-opacity"
|
||||
<div className="relative z-0 flex flex-col h-full bg-gradient-main">
|
||||
<AnimatePresence mode="wait">
|
||||
{!hasMessages ? (
|
||||
<motion.div
|
||||
key="welcome"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="flex-1 overflow-y-auto"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
AI анализирует...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Search className="w-4 h-4" />
|
||||
Получить консультацию AI
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<div className="flex flex-col items-center justify-center min-h-full px-4 sm:px-6 py-8">
|
||||
<div className="w-full max-w-2xl">
|
||||
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} className="text-center mb-8">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl active-gradient flex items-center justify-center">
|
||||
<HeartPulse className="w-8 h-8" />
|
||||
</div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-primary tracking-tight">
|
||||
Медицинский <span className="text-gradient">консультант</span>
|
||||
</h1>
|
||||
<p className="text-sm text-secondary mt-2 max-w-xl mx-auto">
|
||||
Опишите симптомы — я помогу с диф-оценкой, подберу профильного врача, найду варианты записи и время приёма.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* AI Response */}
|
||||
{aiResponse && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mt-4 p-4 bg-surface/50 border border-border/40 rounded-xl"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Shield className="w-4 h-4 text-gradient" />
|
||||
<span className="text-xs font-medium text-gradient">Рекомендация AI</span>
|
||||
<div className="mb-4 p-3 bg-amber-500/5 border border-amber-500/20 rounded-xl">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-600 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-muted">
|
||||
Информация носит справочный характер. Таблетки и дозировки не назначаются. При сильной боли, одышке, потере сознания — 103/112.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 }}>
|
||||
<div className="bg-elevated/60 backdrop-blur-xl border border-border/50 rounded-2xl overflow-hidden">
|
||||
<div className="px-4 pt-4">
|
||||
<label className="text-ui-sm text-muted flex items-center gap-1.5 mb-2">
|
||||
<MapPin className="w-3.5 h-3.5" />
|
||||
Город для записи
|
||||
</label>
|
||||
<input
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
placeholder="Москва"
|
||||
className="w-full h-10 px-3 bg-surface/40 border border-border/40 rounded-lg text-sm text-primary"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end gap-3 p-4">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Опишите симптомы и жалобы..."
|
||||
className="flex-1 bg-transparent text-base text-primary resize-none focus:outline-none min-h-[28px] max-h-[120px] placeholder:text-muted leading-relaxed"
|
||||
rows={1}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!inputValue.trim()}
|
||||
className={`w-10 h-10 flex items-center justify-center rounded-xl transition-all ${
|
||||
inputValue.trim() ? 'btn-gradient text-accent' : 'bg-surface/50 text-muted border border-border/50'
|
||||
}`}
|
||||
aria-label="Отправить"
|
||||
>
|
||||
<ArrowUp className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-2 gap-2">
|
||||
{quickPrompts.map((prompt, i) => (
|
||||
<motion.button
|
||||
key={prompt.text}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 + i * 0.05 }}
|
||||
onClick={() => sendMessage(prompt.query, city)}
|
||||
className="flex items-center gap-2 px-4 py-3 bg-elevated/40 border border-border/40 rounded-xl hover:border-border hover:bg-elevated/60 transition-all text-left"
|
||||
>
|
||||
<span className="text-xl">{prompt.icon}</span>
|
||||
<span className="text-sm text-secondary">{prompt.text}</span>
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-secondary leading-relaxed">{aiResponse}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Services */}
|
||||
<div className="grid grid-cols-4 gap-2 sm:gap-3 mb-6 sm:mb-8">
|
||||
{quickServices.map((service) => (
|
||||
<button
|
||||
key={service.label}
|
||||
className={`flex flex-col items-center gap-2 p-3 sm:p-4 rounded-xl border border-border/40 hover:border-border transition-all ${service.color}`}
|
||||
>
|
||||
<service.icon className="w-5 h-5 sm:w-6 sm:h-6" />
|
||||
<span className="text-[10px] sm:text-xs font-medium text-center leading-tight">
|
||||
{service.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Common Symptoms */}
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-9 h-9 rounded-xl bg-red-500/10 flex items-center justify-center">
|
||||
<HeartPulse className="w-4 h-4 text-red-600" />
|
||||
</div>
|
||||
<h2 className="text-sm font-medium text-primary">Частые симптомы</h2>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div key="chat" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex flex-col h-full">
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<div className="flex flex-col w-full lg:w-[48%] min-w-0">
|
||||
<div className="flex-1 overflow-y-auto px-3 sm:px-4 py-4">
|
||||
<div className="space-y-4">
|
||||
{messages.map((message, i) => (
|
||||
<motion.div
|
||||
key={message.id}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.02 }}
|
||||
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
{message.role === 'user' ? (
|
||||
<div className="max-w-[85%] px-3 py-2 bg-accent/20 border border-accent/30 rounded-xl rounded-br-md">
|
||||
<p className="text-ui text-primary whitespace-pre-wrap">{message.content}</p>
|
||||
</div>
|
||||
) : (
|
||||
<AssistantMessage message={message} />
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{commonSymptoms.map((symptom) => (
|
||||
<SymptomButton
|
||||
key={symptom.id}
|
||||
symptom={symptom}
|
||||
onClick={() => handleSymptomClick(symptom.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{(allWidgets.length > 0 || isLoading) && showPanel && (
|
||||
<div className="lg:hidden border-t border-border/30 bg-base/80 backdrop-blur-sm h-[46vh] min-h-[260px] max-h-[560px]">
|
||||
<MedicineWidgetTabs widgets={allWidgets} isLoading={isLoading} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Health Articles */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-9 h-9 rounded-xl bg-green-500/10 flex items-center justify-center">
|
||||
<FileText className="w-4 h-4 text-green-600" />
|
||||
<div className="px-3 sm:px-4 pb-3 pt-2 bg-base/95">
|
||||
<div className="flex items-center gap-1.5 mb-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => setShowPanel((v) => !v)}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1 text-ui-sm rounded-lg transition-colors ${
|
||||
showPanel ? 'bg-accent/20 text-accent' : 'bg-surface/50 text-secondary'
|
||||
}`}
|
||||
>
|
||||
Панель врача
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
clearChat();
|
||||
setInputValue('');
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 text-ui-sm bg-surface/50 text-secondary rounded-lg hover:text-primary transition-colors"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
Новый
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-elevated/60 backdrop-blur-xl border border-border/50 rounded-xl overflow-hidden">
|
||||
<div className="flex items-end gap-2 p-3">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onInput={handleInput}
|
||||
placeholder="Уточните симптомы или попросите запись к врачу..."
|
||||
className="flex-1 bg-transparent text-sm text-primary resize-none focus:outline-none min-h-[24px] max-h-[100px] placeholder:text-muted leading-relaxed"
|
||||
rows={1}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<button
|
||||
onClick={stopGeneration}
|
||||
className="w-9 h-9 flex items-center justify-center rounded-lg bg-error/10 text-error border border-error/30"
|
||||
aria-label="Остановить"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!inputValue.trim()}
|
||||
className={`w-9 h-9 flex items-center justify-center rounded-lg transition-all ${
|
||||
inputValue.trim() ? 'btn-gradient text-accent' : 'bg-surface/50 text-muted border border-border/50'
|
||||
}`}
|
||||
aria-label="Отправить"
|
||||
>
|
||||
<ArrowUp className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden lg:block w-[52%] border-l border-border/30">
|
||||
<div className="h-full p-3">
|
||||
{allWidgets.length > 0 || isLoading ? (
|
||||
<MedicineWidgetTabs widgets={allWidgets} isLoading={isLoading} />
|
||||
) : (
|
||||
<div className="h-full rounded-xl border border-border/40 bg-surface/20 flex flex-col items-center justify-center gap-3 p-4">
|
||||
<HeartPulse className="w-8 h-8 text-muted/30" />
|
||||
<p className="text-sm text-muted text-center">Панель диагностики появится<br />после первого ответа</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-sm font-medium text-primary">Полезные статьи</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{healthArticles.map((article, i) => (
|
||||
<ArticleCard key={article.id} article={article} delay={i * 0.05} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Emergency Info */}
|
||||
<div className="bg-red-500/5 border border-red-500/20 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertTriangle className="w-4 h-4 text-red-600" />
|
||||
<span className="text-sm font-medium text-red-600">Экстренная помощь</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted mb-3">
|
||||
При угрозе жизни немедленно вызывайте скорую помощь
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<a
|
||||
href="tel:103"
|
||||
className="flex-1 h-10 flex items-center justify-center gap-2 bg-red-500/10 border border-red-500/30 text-red-600 font-medium text-sm rounded-lg hover:bg-red-500/20 transition-colors"
|
||||
>
|
||||
📞 103
|
||||
</a>
|
||||
<a
|
||||
href="tel:112"
|
||||
className="flex-1 h-10 flex items-center justify-center gap-2 bg-red-500/10 border border-red-500/30 text-red-600 font-medium text-sm rounded-lg hover:bg-red-500/20 transition-colors"
|
||||
>
|
||||
📞 112
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -240,7 +240,7 @@ export default function SpacesPage() {
|
||||
{member.avatar ? (
|
||||
<img src={member.avatar} alt="" className="w-full h-full rounded-full object-cover" />
|
||||
) : (
|
||||
<span className="text-[10px] font-medium text-secondary">
|
||||
<span className="text-2xs font-medium text-secondary">
|
||||
{(member.name || member.email || '?').charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
@@ -248,7 +248,7 @@ export default function SpacesPage() {
|
||||
))}
|
||||
{(space.memberCount || 0) > 4 && (
|
||||
<div className="w-7 h-7 rounded-full bg-surface border-2 border-base flex items-center justify-center">
|
||||
<span className="text-[10px] font-medium text-muted">
|
||||
<span className="text-2xs font-medium text-muted">
|
||||
+{(space.memberCount || 0) - 4}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -6,19 +6,22 @@ import {
|
||||
Plane,
|
||||
MapPin,
|
||||
Calendar,
|
||||
Sparkles,
|
||||
Loader2,
|
||||
X,
|
||||
ArrowUp,
|
||||
Bookmark,
|
||||
Map as MapIcon,
|
||||
Plus,
|
||||
ChevronUp,
|
||||
Columns2,
|
||||
PanelBottom,
|
||||
} from 'lucide-react';
|
||||
import { useTravelChat, type TravelChatMessage } from '@/lib/hooks/useTravelChat';
|
||||
import { TravelWidgetRenderer } from '@/components/TravelWidgets';
|
||||
import { useTravelChat, type TravelChatMessage, type TravelWidget } from '@/lib/hooks/useTravelChat';
|
||||
import { TravelWidgetTabs } from '@/components/TravelWidgetTabs';
|
||||
import { TravelMap } from '@/components/TravelMap';
|
||||
import { fetchTrips, createTrip, deleteTrip } from '@/lib/api';
|
||||
import type { Trip, RoutePoint, GeoLocation, EventCard, POICard, HotelCard, TransportOption } from '@/lib/types';
|
||||
import type { Trip, RoutePoint, GeoLocation, EventCard, POICard, HotelCard, TransportOption, ItineraryDay } from '@/lib/types';
|
||||
import type { LLMValidationResponse } from '@/lib/hooks/useEditableItinerary';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
|
||||
const quickPrompts = [
|
||||
@@ -80,86 +83,28 @@ function TripCard({ trip, onClick, onDelete }: TripCardProps) {
|
||||
);
|
||||
}
|
||||
|
||||
interface AssistantMessageProps {
|
||||
message: TravelChatMessage;
|
||||
onAddEventToMap: (event: EventCard) => void;
|
||||
onAddPOIToMap: (poi: POICard) => void;
|
||||
onSelectHotel: (hotel: HotelCard) => void;
|
||||
onSelectTransport: (option: TransportOption) => void;
|
||||
onClarifyingAnswer: (field: string, value: string) => void;
|
||||
onAction: (kind: string) => void;
|
||||
selectedEventIds: Set<string>;
|
||||
selectedPOIIds: Set<string>;
|
||||
selectedHotelId?: string;
|
||||
selectedTransportId?: string;
|
||||
}
|
||||
|
||||
function AssistantMessage({
|
||||
message,
|
||||
onAddEventToMap,
|
||||
onAddPOIToMap,
|
||||
onSelectHotel,
|
||||
onSelectTransport,
|
||||
onClarifyingAnswer,
|
||||
onAction,
|
||||
selectedEventIds,
|
||||
selectedPOIIds,
|
||||
selectedHotelId,
|
||||
selectedTransportId,
|
||||
}: AssistantMessageProps) {
|
||||
const travelWidgets = useMemo(
|
||||
() => message.widgets.filter((w) => w.type.startsWith('travel_')),
|
||||
[message.widgets]
|
||||
);
|
||||
|
||||
function AssistantMessage({ message }: { message: TravelChatMessage }) {
|
||||
return (
|
||||
<div className="max-w-full w-full">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 rounded-lg active-gradient flex items-center justify-center flex-shrink-0">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
{message.content && (
|
||||
<div className="prose prose-sm prose-invert max-w-none">
|
||||
<ReactMarkdown>{message.content}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
{message.isStreaming && !message.content && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-accent" />
|
||||
<span className="text-xs text-muted">Планирую маршрут...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
{message.content && (
|
||||
<div className="prose prose-sm prose-invert max-w-none text-ui">
|
||||
<ReactMarkdown>{message.content}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
{message.isStreaming && !message.content && (
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin text-accent" />
|
||||
<span className="text-xs text-muted">Планирую маршрут...</span>
|
||||
</div>
|
||||
)}
|
||||
{message.isStreaming && message.content && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin text-accent" />
|
||||
<span className="text-xs text-muted">Собираю данные...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{travelWidgets.length > 0 && (
|
||||
<div className="mt-4 ml-11 space-y-3">
|
||||
{travelWidgets.map((widget) => (
|
||||
<TravelWidgetRenderer
|
||||
key={widget.id}
|
||||
widget={widget}
|
||||
onAddEventToMap={onAddEventToMap}
|
||||
onAddPOIToMap={onAddPOIToMap}
|
||||
onSelectHotel={onSelectHotel}
|
||||
onSelectTransport={onSelectTransport}
|
||||
onClarifyingAnswer={onClarifyingAnswer}
|
||||
onAction={onAction}
|
||||
selectedEventIds={selectedEventIds}
|
||||
selectedPOIIds={selectedPOIIds}
|
||||
selectedHotelId={selectedHotelId}
|
||||
selectedTransportId={selectedTransportId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.isStreaming && message.content && (
|
||||
<div className="flex items-center gap-2 mt-3 ml-11">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-accent" />
|
||||
<span className="text-xs text-muted">Собираю данные...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -175,10 +120,6 @@ export default function TravelPage() {
|
||||
budget: 0,
|
||||
});
|
||||
const [showOptions, setShowOptions] = useState(false);
|
||||
const [selectedEventIds, setSelectedEventIds] = useState<Set<string>>(new Set());
|
||||
const [selectedPOIIds, setSelectedPOIIds] = useState<Set<string>>(new Set());
|
||||
const [selectedHotelId, setSelectedHotelId] = useState<string>();
|
||||
const [selectedTransportId, setSelectedTransportId] = useState<string>();
|
||||
const [userLocation, setUserLocation] = useState<GeoLocation | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -202,24 +143,67 @@ export default function TravelPage() {
|
||||
messages,
|
||||
isLoading,
|
||||
isResearching,
|
||||
loadingPhase,
|
||||
currentRoute,
|
||||
routeDirection,
|
||||
routeSegments,
|
||||
pois: availablePois,
|
||||
events: availableEvents,
|
||||
selectedEventIds,
|
||||
selectedPOIIds,
|
||||
selectedHotelId,
|
||||
selectedTransportId,
|
||||
sendMessage,
|
||||
stopGeneration,
|
||||
clearChat,
|
||||
addRoutePoint,
|
||||
answerClarifying,
|
||||
handleAction,
|
||||
addEventToRoute,
|
||||
addPOIToRoute,
|
||||
selectHotelOnRoute,
|
||||
toggleEventSelection,
|
||||
togglePOISelection,
|
||||
toggleHotelSelection,
|
||||
toggleTransportSelection,
|
||||
} = useTravelChat({
|
||||
onRouteUpdate: (route) => {
|
||||
if (route.length > 0) setShowMap(true);
|
||||
},
|
||||
});
|
||||
|
||||
const handleItineraryUpdate = useCallback((days: ItineraryDay[]) => {
|
||||
sendMessage(
|
||||
`_clarify:Пользователь отредактировал маршрут. Вот обновлённый маршрут:\n${JSON.stringify(days, null, 2)}\n\nПожалуйста, пересчитай бюджет и обнови маршрут.`,
|
||||
);
|
||||
}, [sendMessage]);
|
||||
|
||||
const handleValidateItineraryWithLLM = useCallback(
|
||||
async (days: ItineraryDay[]): Promise<LLMValidationResponse | null> => {
|
||||
try {
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
|
||||
const headers: HeadersInit = { 'Content-Type': 'application/json' };
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
const response = await fetch(`${API_BASE}/api/v1/travel/validate-itinerary`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
days,
|
||||
pois: availablePois,
|
||||
events: availableEvents,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.json() as LLMValidationResponse;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[availablePois, availableEvents],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadTrips();
|
||||
}, []);
|
||||
@@ -264,42 +248,20 @@ export default function TravelPage() {
|
||||
}, [sendMessage, planOptions, userLocation]);
|
||||
|
||||
const handleAddEventToMap = useCallback((event: EventCard) => {
|
||||
setSelectedEventIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(event.id)) {
|
||||
next.delete(event.id);
|
||||
} else {
|
||||
next.add(event.id);
|
||||
addEventToRoute(event);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [addEventToRoute]);
|
||||
toggleEventSelection(event);
|
||||
}, [toggleEventSelection]);
|
||||
|
||||
const handleAddPOIToMap = useCallback((poi: POICard) => {
|
||||
setSelectedPOIIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(poi.id)) {
|
||||
next.delete(poi.id);
|
||||
} else {
|
||||
next.add(poi.id);
|
||||
addPOIToRoute(poi);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [addPOIToRoute]);
|
||||
togglePOISelection(poi);
|
||||
}, [togglePOISelection]);
|
||||
|
||||
const handleSelectHotel = useCallback((hotel: HotelCard) => {
|
||||
setSelectedHotelId((prev) => {
|
||||
const newId = prev === hotel.id ? undefined : hotel.id;
|
||||
if (newId) selectHotelOnRoute(hotel);
|
||||
return newId;
|
||||
});
|
||||
}, [selectHotelOnRoute]);
|
||||
toggleHotelSelection(hotel);
|
||||
}, [toggleHotelSelection]);
|
||||
|
||||
const handleSelectTransport = useCallback((option: TransportOption) => {
|
||||
setSelectedTransportId((prev) => (prev === option.id ? undefined : option.id));
|
||||
}, []);
|
||||
toggleTransportSelection(option);
|
||||
}, [toggleTransportSelection]);
|
||||
|
||||
const handleMapClick = useCallback((location: GeoLocation) => {
|
||||
const point: RoutePoint = {
|
||||
@@ -367,8 +329,35 @@ export default function TravelPage() {
|
||||
|
||||
const hasMessages = messages.length > 0;
|
||||
|
||||
const allWidgets = useMemo((): TravelWidget[] => {
|
||||
const widgetMap = new Map<string, TravelWidget>();
|
||||
for (const msg of messages) {
|
||||
if (msg.role !== 'assistant') continue;
|
||||
for (const w of msg.widgets) {
|
||||
if (w.type.startsWith('travel_')) {
|
||||
widgetMap.set(w.type, w);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(widgetMap.values());
|
||||
}, [messages]);
|
||||
|
||||
const hasTravelWidgets = allWidgets.length > 0;
|
||||
const [showWidgetPanel, setShowWidgetPanel] = useState(true);
|
||||
const [isDesktop, setIsDesktop] = useState(false);
|
||||
const [rightPanelMode, setRightPanelMode] = useState<'map' | 'split' | 'panel'>('map');
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const mq = window.matchMedia('(min-width: 1024px)');
|
||||
const apply = () => setIsDesktop(mq.matches);
|
||||
apply();
|
||||
mq.addEventListener?.('change', apply);
|
||||
return () => mq.removeEventListener?.('change', apply);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-gradient-main">
|
||||
<div className="relative z-0 flex flex-col h-full bg-gradient-main">
|
||||
<AnimatePresence mode="wait">
|
||||
{!hasMessages ? (
|
||||
<motion.div
|
||||
@@ -411,7 +400,7 @@ export default function TravelPage() {
|
||||
onKeyDown={handleKeyDown}
|
||||
onInput={handleInput}
|
||||
placeholder="Опишите своё идеальное путешествие..."
|
||||
className="flex-1 bg-transparent text-[15px] text-primary resize-none focus:outline-none min-h-[28px] max-h-[120px] placeholder:text-muted leading-relaxed"
|
||||
className="flex-1 bg-transparent text-base text-primary resize-none focus:outline-none min-h-[28px] max-h-[120px] placeholder:text-muted leading-relaxed"
|
||||
rows={1}
|
||||
autoFocus
|
||||
/>
|
||||
@@ -563,44 +552,23 @@ export default function TravelPage() {
|
||||
className="flex flex-col h-full"
|
||||
>
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<div className={`flex flex-col min-w-0 ${showMap ? 'w-full lg:w-1/2' : 'w-full'}`}>
|
||||
{isResearching && (
|
||||
<div className="px-4 py-2 bg-accent/5 border-b border-accent/20">
|
||||
<div className="flex items-center gap-2 max-w-3xl mx-auto">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-accent" />
|
||||
<span className="text-xs text-accent">Исследую маршруты, мероприятия и отели...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 sm:px-6 py-6">
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
<div className={`flex flex-col min-w-0 ${showMap ? 'w-full lg:w-[45%]' : 'w-full'}`}>
|
||||
<div className="flex-1 overflow-y-auto px-3 sm:px-4 py-4">
|
||||
<div className="space-y-4">
|
||||
{messages.map((message, i) => (
|
||||
<motion.div
|
||||
key={message.id}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.02 }}
|
||||
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
{message.role === 'user' ? (
|
||||
<div className="max-w-[85%] px-4 py-3 bg-accent/20 border border-accent/30 rounded-2xl rounded-br-md">
|
||||
<p className="text-sm text-primary whitespace-pre-wrap">{message.content}</p>
|
||||
<div className="max-w-[85%] px-3 py-2 bg-accent/20 border border-accent/30 rounded-xl rounded-br-md">
|
||||
<p className="text-ui text-primary whitespace-pre-wrap">{message.content}</p>
|
||||
</div>
|
||||
) : (
|
||||
<AssistantMessage
|
||||
message={message}
|
||||
onAddEventToMap={handleAddEventToMap}
|
||||
onAddPOIToMap={handleAddPOIToMap}
|
||||
onSelectHotel={handleSelectHotel}
|
||||
onSelectTransport={handleSelectTransport}
|
||||
onClarifyingAnswer={answerClarifying}
|
||||
onAction={handleAction}
|
||||
selectedEventIds={selectedEventIds}
|
||||
selectedPOIIds={selectedPOIIds}
|
||||
selectedHotelId={selectedHotelId}
|
||||
selectedTransportId={selectedTransportId}
|
||||
/>
|
||||
<AssistantMessage message={message} />
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
@@ -608,92 +576,139 @@ export default function TravelPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sticky bottom-0 px-4 sm:px-6 pb-4 pt-4 bg-gradient-to-t from-base via-base/95 to-transparent">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{currentRoute.length > 0 && (
|
||||
<button
|
||||
onClick={handleSaveTrip}
|
||||
disabled={saveStatus === 'saving'}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 text-xs rounded-lg transition-colors ${
|
||||
saveStatus === 'saved'
|
||||
? 'bg-green-500/20 text-green-600'
|
||||
: saveStatus === 'error'
|
||||
? 'bg-error/20 text-error'
|
||||
: 'bg-accent/20 text-accent hover:bg-accent/30'
|
||||
}`}
|
||||
>
|
||||
{saveStatus === 'saving' ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Bookmark className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{saveStatus === 'saved' ? 'Сохранено!' : saveStatus === 'error' ? 'Ошибка' : 'Сохранить поездку'}
|
||||
</button>
|
||||
)}
|
||||
{/* Mobile: keep widgets under chat */}
|
||||
{hasTravelWidgets && showWidgetPanel && (
|
||||
<div className="lg:hidden border-t border-border/30 bg-base/80 backdrop-blur-sm">
|
||||
<div className="h-[45vh] min-h-[260px] max-h-[520px]">
|
||||
<TravelWidgetTabs
|
||||
widgets={allWidgets}
|
||||
onAddEventToMap={handleAddEventToMap}
|
||||
onAddPOIToMap={handleAddPOIToMap}
|
||||
onSelectHotel={handleSelectHotel}
|
||||
onSelectTransport={handleSelectTransport}
|
||||
onClarifyingAnswer={answerClarifying}
|
||||
onAction={handleAction}
|
||||
selectedEventIds={selectedEventIds}
|
||||
selectedPOIIds={selectedPOIIds}
|
||||
selectedHotelId={selectedHotelId}
|
||||
selectedTransportId={selectedTransportId}
|
||||
availablePois={availablePois}
|
||||
availableEvents={availableEvents}
|
||||
onItineraryUpdate={handleItineraryUpdate}
|
||||
onValidateItineraryWithLLM={handleValidateItineraryWithLLM}
|
||||
isLoading={isLoading}
|
||||
loadingPhase={loadingPhase}
|
||||
isResearching={isResearching}
|
||||
routePointCount={currentRoute.length}
|
||||
hasRouteDirection={Boolean(routeDirection)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-3 sm:px-4 pb-3 pt-2 bg-base/95">
|
||||
<div className="flex items-center gap-1.5 mb-2 flex-wrap">
|
||||
{currentRoute.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowMap(!showMap)}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 text-xs rounded-lg transition-colors ${
|
||||
showMap ? 'bg-accent/20 text-accent' : 'bg-surface/50 text-secondary'
|
||||
onClick={handleSaveTrip}
|
||||
disabled={saveStatus === 'saving'}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1 text-ui-sm rounded-lg transition-colors ${
|
||||
saveStatus === 'saved'
|
||||
? 'bg-green-500/20 text-green-600'
|
||||
: saveStatus === 'error'
|
||||
? 'bg-error/20 text-error'
|
||||
: 'bg-accent/20 text-accent hover:bg-accent/30'
|
||||
}`}
|
||||
>
|
||||
<MapIcon className="w-3.5 h-3.5" />
|
||||
{showMap ? 'Скрыть карту' : 'Показать карту'}
|
||||
{currentRoute.length > 0 && (
|
||||
<span className="ml-1 px-1.5 py-0.5 bg-accent/30 rounded text-[10px]">
|
||||
{currentRoute.length}
|
||||
</span>
|
||||
{saveStatus === 'saving' ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<Bookmark className="w-3 h-3" />
|
||||
)}
|
||||
{saveStatus === 'saved' ? 'Сохранено' : saveStatus === 'error' ? 'Ошибка' : 'Сохранить'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowMap(!showMap)}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1 text-ui-sm rounded-lg transition-colors ${
|
||||
showMap ? 'bg-accent/20 text-accent' : 'bg-surface/50 text-secondary'
|
||||
}`}
|
||||
>
|
||||
<MapIcon className="w-3 h-3" />
|
||||
Карта
|
||||
{currentRoute.length > 0 && (
|
||||
<span className="px-1 py-0.5 bg-accent/30 rounded text-3xs">
|
||||
{currentRoute.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{hasTravelWidgets && (
|
||||
<button
|
||||
onClick={() => {
|
||||
clearChat();
|
||||
setInputValue('');
|
||||
setSaveStatus('idle');
|
||||
if (isDesktop) {
|
||||
setRightPanelMode((prev) => (prev === 'map' ? 'split' : prev === 'split' ? 'panel' : 'map'));
|
||||
} else {
|
||||
setShowWidgetPanel(!showWidgetPanel);
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-xs bg-surface/50 text-secondary rounded-lg hover:text-primary transition-colors"
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1 text-ui-sm rounded-lg transition-colors ${
|
||||
(isDesktop ? rightPanelMode !== 'map' : showWidgetPanel)
|
||||
? 'bg-accent/20 text-accent'
|
||||
: 'bg-surface/50 text-secondary'
|
||||
}`}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Новый план
|
||||
<Calendar className="w-3 h-3" />
|
||||
Панель
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
clearChat();
|
||||
setInputValue('');
|
||||
setSaveStatus('idle');
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 text-ui-sm bg-surface/50 text-secondary rounded-lg hover:text-primary transition-colors"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
Новый
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-elevated/60 backdrop-blur-xl border border-border/50 rounded-2xl overflow-hidden">
|
||||
<div className="flex items-end gap-3 p-4">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onInput={handleInput}
|
||||
placeholder="Уточните детали или задайте вопрос..."
|
||||
className="flex-1 bg-transparent text-[15px] text-primary resize-none focus:outline-none min-h-[28px] max-h-[120px] placeholder:text-muted leading-relaxed"
|
||||
rows={1}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<button
|
||||
onClick={stopGeneration}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-xl bg-error/10 text-error border border-error/30"
|
||||
aria-label="Остановить"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!inputValue.trim()}
|
||||
className={`w-10 h-10 flex items-center justify-center rounded-xl transition-all ${
|
||||
inputValue.trim()
|
||||
? 'btn-gradient text-accent'
|
||||
: 'bg-surface/50 text-muted border border-border/50'
|
||||
}`}
|
||||
aria-label="Отправить"
|
||||
>
|
||||
<ArrowUp className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-elevated/60 backdrop-blur-xl border border-border/50 rounded-xl overflow-hidden">
|
||||
<div className="flex items-end gap-2 p-3">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onInput={handleInput}
|
||||
placeholder="Уточните детали или задайте вопрос..."
|
||||
className="flex-1 bg-transparent text-sm text-primary resize-none focus:outline-none min-h-[24px] max-h-[100px] placeholder:text-muted leading-relaxed"
|
||||
rows={1}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<button
|
||||
onClick={stopGeneration}
|
||||
className="w-9 h-9 flex items-center justify-center rounded-lg bg-error/10 text-error border border-error/30"
|
||||
aria-label="Остановить"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!inputValue.trim()}
|
||||
className={`w-9 h-9 flex items-center justify-center rounded-lg transition-all ${
|
||||
inputValue.trim()
|
||||
? 'btn-gradient text-accent'
|
||||
: 'bg-surface/50 text-muted border border-border/50'
|
||||
}`}
|
||||
aria-label="Отправить"
|
||||
>
|
||||
<ArrowUp className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -701,16 +716,109 @@ export default function TravelPage() {
|
||||
|
||||
<div
|
||||
className={`hidden lg:block border-l border-border/30 transition-all duration-300 ${
|
||||
showMap ? 'w-1/2 opacity-100' : 'w-0 opacity-0 overflow-hidden'
|
||||
showMap ? 'w-[55%] opacity-100' : 'w-0 opacity-0 overflow-hidden'
|
||||
}`}
|
||||
>
|
||||
<TravelMap
|
||||
route={currentRoute}
|
||||
routeDirection={routeDirection ?? undefined}
|
||||
onMapClick={handleMapClick}
|
||||
className="h-full"
|
||||
userLocation={userLocation}
|
||||
/>
|
||||
<div className="relative h-full">
|
||||
<TravelMap
|
||||
route={currentRoute}
|
||||
routeDirection={routeDirection ?? undefined}
|
||||
onMapClick={handleMapClick}
|
||||
className="h-full"
|
||||
userLocation={userLocation}
|
||||
/>
|
||||
|
||||
{/* Desktop: widgets drawer over the map (3 modes) */}
|
||||
{hasTravelWidgets && (
|
||||
<>
|
||||
<div className="absolute top-3 right-3 z-20 flex items-center gap-1 bg-base border border-border/50 rounded-xl p-1 shadow-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRightPanelMode('map')}
|
||||
className={`px-2.5 py-1.5 text-ui-sm rounded-lg transition-colors ${
|
||||
rightPanelMode === 'map' ? 'bg-accent/20 text-accent' : 'text-secondary hover:text-primary hover:bg-surface/30'
|
||||
}`}
|
||||
title="Карта"
|
||||
aria-label="Карта"
|
||||
>
|
||||
<MapIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRightPanelMode('split')}
|
||||
className={`px-2.5 py-1.5 text-ui-sm rounded-lg transition-colors ${
|
||||
rightPanelMode === 'split' ? 'bg-accent/20 text-accent' : 'text-secondary hover:text-primary hover:bg-surface/30'
|
||||
}`}
|
||||
title="Карта + панель"
|
||||
aria-label="Карта + панель"
|
||||
>
|
||||
<Columns2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRightPanelMode('panel')}
|
||||
className={`px-2.5 py-1.5 text-ui-sm rounded-lg transition-colors ${
|
||||
rightPanelMode === 'panel' ? 'bg-accent/20 text-accent' : 'text-secondary hover:text-primary hover:bg-surface/30'
|
||||
}`}
|
||||
title="Панель"
|
||||
aria-label="Панель"
|
||||
>
|
||||
<PanelBottom className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{rightPanelMode !== 'map' && (
|
||||
<div
|
||||
className={`absolute left-3 right-3 bottom-3 z-10 transition-all duration-200 ${
|
||||
rightPanelMode === 'panel' ? 'top-3' : ''
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`h-full flex flex-col ${
|
||||
rightPanelMode === 'split' ? 'h-[42%] min-h-[260px] max-h-[520px]' : ''
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRightPanelMode((m) => (m === 'split' ? 'panel' : 'split'))}
|
||||
className="mb-2 w-full flex items-center justify-between px-3 py-2 text-xs rounded-xl bg-base border border-border/50 hover:border-accent/30 transition-colors shadow-sm"
|
||||
aria-label="Изменить размер панели"
|
||||
title="Изменить размер панели"
|
||||
>
|
||||
<span className="text-secondary">Панель этапов</span>
|
||||
<ChevronUp className={`w-4 h-4 text-muted transition-transform ${rightPanelMode === 'panel' ? '' : 'rotate-180'}`} />
|
||||
</button>
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
<TravelWidgetTabs
|
||||
widgets={allWidgets}
|
||||
onAddEventToMap={handleAddEventToMap}
|
||||
onAddPOIToMap={handleAddPOIToMap}
|
||||
onSelectHotel={handleSelectHotel}
|
||||
onSelectTransport={handleSelectTransport}
|
||||
onClarifyingAnswer={answerClarifying}
|
||||
onAction={handleAction}
|
||||
selectedEventIds={selectedEventIds}
|
||||
selectedPOIIds={selectedPOIIds}
|
||||
selectedHotelId={selectedHotelId}
|
||||
selectedTransportId={selectedTransportId}
|
||||
availablePois={availablePois}
|
||||
availableEvents={availableEvents}
|
||||
onItineraryUpdate={handleItineraryUpdate}
|
||||
onValidateItineraryWithLLM={handleValidateItineraryWithLLM}
|
||||
isLoading={isLoading}
|
||||
loadingPhase={loadingPhase}
|
||||
isResearching={isResearching}
|
||||
routePointCount={currentRoute.length}
|
||||
hasRouteDirection={Boolean(routeDirection)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -720,7 +828,7 @@ export default function TravelPage() {
|
||||
route={currentRoute}
|
||||
routeDirection={routeDirection ?? undefined}
|
||||
onMapClick={handleMapClick}
|
||||
className="h-[300px]"
|
||||
className="h-[250px]"
|
||||
userLocation={userLocation}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -6,42 +6,36 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap');
|
||||
|
||||
:root {
|
||||
/* GooSeek Light Asphalt/Graphite Theme */
|
||||
/* Base backgrounds — light grays with warm undertone */
|
||||
--bg-base: 220 14% 96%;
|
||||
--bg-elevated: 220 13% 100%;
|
||||
--bg-surface: 220 13% 93%;
|
||||
--bg-overlay: 220 12% 89%;
|
||||
--bg-muted: 220 10% 85%;
|
||||
|
||||
/* Text colors — graphite/charcoal for readability */
|
||||
--text-primary: 220 15% 16%;
|
||||
--text-secondary: 220 10% 38%;
|
||||
--text-muted: 220 8% 54%;
|
||||
--text-faint: 220 6% 68%;
|
||||
|
||||
/* Accent colors — slate-blue, professional */
|
||||
--accent: 224 64% 48%;
|
||||
--accent-hover: 224 64% 42%;
|
||||
--accent-muted: 224 45% 58%;
|
||||
--accent-subtle: 224 30% 90%;
|
||||
|
||||
/* Secondary accent — teal for variety */
|
||||
--accent-secondary: 180 50% 38%;
|
||||
--accent-secondary-muted: 180 35% 52%;
|
||||
|
||||
/* Semantic colors */
|
||||
--success: 152 60% 38%;
|
||||
--success-muted: 152 40% 48%;
|
||||
--warning: 38 85% 48%;
|
||||
--warning-muted: 38 60% 55%;
|
||||
--error: 0 65% 50%;
|
||||
--error-muted: 0 50% 58%;
|
||||
|
||||
/* Border colors */
|
||||
--border: 220 12% 86%;
|
||||
--border-hover: 220 12% 78%;
|
||||
--border-focus: 224 64% 48%;
|
||||
/* GitHub Light (approx. 1:1 tokens) */
|
||||
--bg-base: 210 29% 97%; /* #f6f8fa */
|
||||
--bg-elevated: 0 0% 100%; /* #ffffff */
|
||||
--bg-surface: 210 29% 97%; /* #f6f8fa */
|
||||
--bg-overlay: 0 0% 100%; /* #ffffff */
|
||||
--bg-muted: 210 22% 93%; /* ~#eef1f4 */
|
||||
|
||||
--text-primary: 215 19% 17%; /* #24292f */
|
||||
--text-secondary: 215 14% 33%; /* #57606a */
|
||||
--text-muted: 215 10% 45%; /* #6e7781 */
|
||||
--text-faint: 214 10% 60%; /* #8c959f */
|
||||
|
||||
--accent: 213 92% 44%; /* #0969da */
|
||||
--accent-hover: 213 95% 36%; /* ~#0550ae */
|
||||
--accent-muted: 212 92% 64%; /* ~#54aeff */
|
||||
--accent-subtle: 199 100% 92%;/* ~#ddf4ff */
|
||||
|
||||
--accent-secondary: 210 69% 38%;
|
||||
--accent-secondary-muted: 210 60% 52%;
|
||||
|
||||
--success: 140 64% 30%; /* #1a7f37 */
|
||||
--success-muted: 140 48% 38%;
|
||||
--warning: 36 100% 30%; /* ~#9a6700 */
|
||||
--warning-muted: 36 85% 38%;
|
||||
--error: 355 74% 47%; /* #cf222e */
|
||||
--error-muted: 355 60% 55%;
|
||||
|
||||
--border: 210 14% 84%; /* #d0d7de */
|
||||
--border-hover: 213 10% 72%; /* #afb8c1 */
|
||||
--border-focus: 213 92% 44%;
|
||||
|
||||
/* Legacy mappings for compatibility */
|
||||
--background: var(--bg-base);
|
||||
@@ -75,16 +69,60 @@ html {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
html.theme-dim {
|
||||
color-scheme: dark;
|
||||
|
||||
/* GitHub Dark Dimmed (approx. 1:1 tokens) */
|
||||
--bg-base: 210 13% 15%; /* #22272e */
|
||||
--bg-elevated: 210 12% 20%; /* #2d333b */
|
||||
--bg-surface: 210 12% 20%; /* #2d333b */
|
||||
--bg-overlay: 210 10% 25%; /* #373e47 */
|
||||
--bg-muted: 210 15% 13%; /* #1c2128 */
|
||||
|
||||
--text-primary: 210 17% 73%; /* #adbac7 */
|
||||
--text-secondary: 211 13% 49%; /* #768390 */
|
||||
--text-muted: 212 11% 41%; /* ~#636e7b */
|
||||
--text-faint: 213 11% 33%; /* ~#545d68 */
|
||||
|
||||
--accent: 212 89% 64%; /* #539bf5 */
|
||||
--accent-hover: 212 74% 58%; /* ~#4184e4 */
|
||||
--accent-muted: 212 82% 70%;
|
||||
--accent-subtle: 214 53% 20%; /* ~#143d79 */
|
||||
|
||||
--accent-secondary: 199 80% 55%;
|
||||
--accent-secondary-muted: 199 70% 62%;
|
||||
|
||||
--success: 135 52% 48%; /* ~#57ab5a */
|
||||
--success-muted: 135 42% 56%;
|
||||
--warning: 39 75% 56%; /* ~#c69026 */
|
||||
--warning-muted: 39 65% 62%;
|
||||
--error: 0 75% 58%; /* ~#e5534b */
|
||||
--error-muted: 0 65% 64%;
|
||||
|
||||
--border: 212 12% 30%; /* #444c56 */
|
||||
--border-hover: 213 11% 36%; /* #545d68 */
|
||||
--border-focus: 212 89% 64%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
background: hsl(var(--bg-base));
|
||||
color: hsl(var(--text-primary));
|
||||
font-size: 0.9375rem; /* match Tailwind text-base override (15px) */
|
||||
line-height: 1.5rem;
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/* ========================
|
||||
Gradient backgrounds
|
||||
======================== */
|
||||
@@ -92,35 +130,35 @@ body {
|
||||
.bg-gradient-main {
|
||||
background: linear-gradient(
|
||||
160deg,
|
||||
hsl(220 16% 96%) 0%,
|
||||
hsl(220 14% 94%) 30%,
|
||||
hsl(225 14% 92%) 60%,
|
||||
hsl(220 12% 95%) 100%
|
||||
hsl(var(--bg-base)) 0%,
|
||||
hsl(var(--bg-surface)) 30%,
|
||||
hsl(var(--bg-muted)) 60%,
|
||||
hsl(var(--bg-base)) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.bg-gradient-elevated {
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
hsl(0 0% 100%) 0%,
|
||||
hsl(220 14% 97%) 100%
|
||||
hsl(var(--bg-elevated)) 0%,
|
||||
hsl(var(--bg-base)) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.bg-gradient-card {
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
hsl(0 0% 100% / 0.9) 0%,
|
||||
hsl(220 14% 96% / 0.7) 100%
|
||||
hsl(var(--bg-elevated) / 0.92) 0%,
|
||||
hsl(var(--bg-base) / 0.72) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.bg-gradient-accent {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(224 64% 48%) 0%,
|
||||
hsl(240 55% 52%) 50%,
|
||||
hsl(224 64% 48%) 100%
|
||||
hsl(var(--accent)) 0%,
|
||||
hsl(var(--accent-hover)) 50%,
|
||||
hsl(var(--accent)) 100%
|
||||
);
|
||||
}
|
||||
|
||||
@@ -131,9 +169,9 @@ body {
|
||||
.text-gradient {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(224 64% 42%) 0%,
|
||||
hsl(240 55% 48%) 50%,
|
||||
hsl(180 50% 38%) 100%
|
||||
hsl(var(--accent-hover)) 0%,
|
||||
hsl(var(--accent)) 55%,
|
||||
hsl(var(--accent-secondary)) 100%
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
@@ -152,7 +190,7 @@ body {
|
||||
padding: 0.625rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: hsl(224 64% 42%);
|
||||
color: hsl(var(--accent-hover));
|
||||
transition: all 0.15s ease;
|
||||
z-index: 1;
|
||||
}
|
||||
@@ -165,9 +203,9 @@ body {
|
||||
padding: 1.5px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(224 64% 48%) 0%,
|
||||
hsl(240 55% 52%) 50%,
|
||||
hsl(180 50% 42%) 100%
|
||||
hsl(var(--accent)) 0%,
|
||||
hsl(var(--accent)) 50%,
|
||||
hsl(var(--accent-secondary)) 100%
|
||||
);
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
@@ -180,9 +218,9 @@ body {
|
||||
.btn-gradient:hover {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(224 64% 48% / 0.08) 0%,
|
||||
hsl(240 55% 52% / 0.06) 50%,
|
||||
hsl(180 50% 42% / 0.05) 100%
|
||||
hsl(var(--accent) / 0.08) 0%,
|
||||
hsl(var(--accent) / 0.06) 55%,
|
||||
hsl(var(--accent-secondary) / 0.05) 100%
|
||||
);
|
||||
}
|
||||
|
||||
@@ -198,7 +236,7 @@ body {
|
||||
padding: 0.75rem 1.25rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: hsl(224 64% 42%);
|
||||
color: hsl(var(--accent-hover));
|
||||
transition: all 0.15s ease;
|
||||
z-index: 1;
|
||||
}
|
||||
@@ -211,9 +249,9 @@ body {
|
||||
padding: 2px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(224 64% 48%) 0%,
|
||||
hsl(240 55% 52%) 50%,
|
||||
hsl(180 50% 42%) 100%
|
||||
hsl(var(--accent)) 0%,
|
||||
hsl(var(--accent)) 50%,
|
||||
hsl(var(--accent-secondary)) 100%
|
||||
);
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
@@ -226,18 +264,18 @@ body {
|
||||
.btn-gradient-lg:hover {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(224 64% 48% / 0.08) 0%,
|
||||
hsl(240 55% 52% / 0.06) 50%,
|
||||
hsl(180 50% 42% / 0.05) 100%
|
||||
hsl(var(--accent) / 0.08) 0%,
|
||||
hsl(var(--accent) / 0.06) 55%,
|
||||
hsl(var(--accent-secondary) / 0.05) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.btn-gradient-text {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(224 64% 42%) 0%,
|
||||
hsl(240 55% 48%) 50%,
|
||||
hsl(180 50% 38%) 100%
|
||||
hsl(var(--accent-hover)) 0%,
|
||||
hsl(var(--accent)) 55%,
|
||||
hsl(var(--accent-secondary)) 100%
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
@@ -252,9 +290,9 @@ body {
|
||||
position: relative;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(224 64% 48% / 0.08) 0%,
|
||||
hsl(240 55% 52% / 0.06) 50%,
|
||||
hsl(180 50% 42% / 0.04) 100%
|
||||
hsl(var(--accent) / 0.08) 0%,
|
||||
hsl(var(--accent) / 0.06) 55%,
|
||||
hsl(var(--accent-secondary) / 0.04) 100%
|
||||
);
|
||||
}
|
||||
|
||||
@@ -266,9 +304,9 @@ body {
|
||||
padding: 1px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(224 64% 48% / 0.3) 0%,
|
||||
hsl(240 55% 52% / 0.2) 50%,
|
||||
hsl(180 50% 42% / 0.15) 100%
|
||||
hsl(var(--accent) / 0.3) 0%,
|
||||
hsl(var(--accent) / 0.2) 55%,
|
||||
hsl(var(--accent-secondary) / 0.15) 100%
|
||||
);
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
@@ -290,9 +328,9 @@ body {
|
||||
padding: 1px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(224 64% 48% / 0.2) 0%,
|
||||
hsl(240 55% 52% / 0.15) 50%,
|
||||
hsl(180 50% 42% / 0.1) 100%
|
||||
hsl(var(--accent) / 0.2) 0%,
|
||||
hsl(var(--accent) / 0.15) 55%,
|
||||
hsl(var(--accent-secondary) / 0.1) 100%
|
||||
);
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
@@ -312,9 +350,9 @@ body {
|
||||
justify-content: center;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(224 64% 48% / 0.1) 0%,
|
||||
hsl(240 55% 52% / 0.07) 50%,
|
||||
hsl(180 50% 42% / 0.05) 100%
|
||||
hsl(var(--accent) / 0.1) 0%,
|
||||
hsl(var(--accent) / 0.07) 55%,
|
||||
hsl(var(--accent-secondary) / 0.05) 100%
|
||||
);
|
||||
position: relative;
|
||||
}
|
||||
@@ -327,9 +365,9 @@ body {
|
||||
padding: 1px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(224 64% 48% / 0.2) 0%,
|
||||
hsl(240 55% 52% / 0.15) 50%,
|
||||
hsl(180 50% 42% / 0.1) 100%
|
||||
hsl(var(--accent) / 0.2) 0%,
|
||||
hsl(var(--accent) / 0.15) 55%,
|
||||
hsl(var(--accent-secondary) / 0.1) 100%
|
||||
);
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
@@ -346,8 +384,8 @@ body {
|
||||
.input-gradient:focus {
|
||||
outline: none;
|
||||
border-color: transparent;
|
||||
box-shadow: 0 0 0 1px hsl(224 64% 48% / 0.35),
|
||||
0 0 0 3px hsl(224 64% 48% / 0.08);
|
||||
box-shadow: 0 0 0 1px hsl(var(--accent) / 0.35),
|
||||
0 0 0 3px hsl(var(--accent) / 0.08);
|
||||
}
|
||||
|
||||
/* ========================
|
||||
@@ -355,36 +393,36 @@ body {
|
||||
======================== */
|
||||
|
||||
.loader-gradient {
|
||||
color: hsl(224 64% 48%);
|
||||
filter: drop-shadow(0 0 6px hsl(224 64% 48% / 0.2));
|
||||
color: hsl(var(--accent));
|
||||
filter: drop-shadow(0 0 6px hsl(var(--accent) / 0.2));
|
||||
}
|
||||
|
||||
.progress-gradient {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
hsl(224 64% 48%) 0%,
|
||||
hsl(240 55% 52%) 50%,
|
||||
hsl(180 50% 42%) 100%
|
||||
hsl(var(--accent)) 0%,
|
||||
hsl(var(--accent-hover)) 55%,
|
||||
hsl(var(--accent-secondary)) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.stat-gradient {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(224 64% 48% / 0.06) 0%,
|
||||
hsl(240 55% 52% / 0.03) 100%
|
||||
hsl(var(--accent) / 0.06) 0%,
|
||||
hsl(var(--accent) / 0.03) 100%
|
||||
);
|
||||
border: 1px solid;
|
||||
border-image: linear-gradient(
|
||||
135deg,
|
||||
hsl(224 64% 48% / 0.18) 0%,
|
||||
hsl(180 50% 42% / 0.1) 100%
|
||||
hsl(var(--accent) / 0.18) 0%,
|
||||
hsl(var(--accent-secondary) / 0.1) 100%
|
||||
) 1;
|
||||
}
|
||||
|
||||
.glow-gradient {
|
||||
box-shadow: 0 4px 16px hsl(224 64% 48% / 0.1),
|
||||
0 1px 4px hsl(220 14% 50% / 0.08);
|
||||
box-shadow: 0 4px 16px hsl(var(--accent) / 0.1),
|
||||
0 1px 4px hsl(var(--text-muted) / 0.08);
|
||||
}
|
||||
|
||||
/* ========================
|
||||
@@ -405,9 +443,9 @@ body {
|
||||
height: 60%;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
hsl(224 64% 48%) 0%,
|
||||
hsl(240 55% 52%) 50%,
|
||||
hsl(180 50% 42%) 100%
|
||||
hsl(var(--accent)) 0%,
|
||||
hsl(var(--accent)) 55%,
|
||||
hsl(var(--accent-secondary)) 100%
|
||||
);
|
||||
border-radius: 1px;
|
||||
}
|
||||
@@ -426,12 +464,12 @@ body {
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: hsl(220 10% 78%);
|
||||
background: hsl(var(--border));
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(220 10% 66%);
|
||||
background: hsl(var(--border-hover));
|
||||
}
|
||||
|
||||
.scrollbar-hide {
|
||||
@@ -445,7 +483,7 @@ body {
|
||||
|
||||
.poi-carousel {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(220 10% 78% / 0.4) transparent;
|
||||
scrollbar-color: hsl(var(--border) / 0.5) transparent;
|
||||
}
|
||||
|
||||
.poi-carousel::-webkit-scrollbar {
|
||||
@@ -457,17 +495,17 @@ body {
|
||||
}
|
||||
|
||||
.poi-carousel::-webkit-scrollbar-thumb {
|
||||
background: hsl(220 10% 78% / 0.4);
|
||||
background: hsl(var(--border) / 0.5);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.poi-carousel::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(220 10% 78% / 0.7);
|
||||
background: hsl(var(--border-hover) / 0.7);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: hsl(224 64% 48% / 0.2);
|
||||
color: hsl(220 15% 16%);
|
||||
background: hsl(var(--accent) / 0.2);
|
||||
color: hsl(var(--text-primary));
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
@@ -483,20 +521,20 @@ body {
|
||||
@layer components {
|
||||
.glass-card {
|
||||
@apply bg-elevated/80 backdrop-blur-xl border border-border rounded-2xl;
|
||||
box-shadow: 0 1px 3px hsl(220 14% 50% / 0.06),
|
||||
0 4px 12px hsl(220 14% 50% / 0.04);
|
||||
box-shadow: 0 1px 3px hsl(var(--text-muted) / 0.1),
|
||||
0 4px 12px hsl(var(--text-muted) / 0.06);
|
||||
}
|
||||
|
||||
.surface-card {
|
||||
@apply bg-elevated/70 backdrop-blur-sm border border-border/60 rounded-xl;
|
||||
@apply transition-all duration-200;
|
||||
box-shadow: 0 1px 2px hsl(220 14% 50% / 0.05);
|
||||
box-shadow: 0 1px 2px hsl(var(--text-muted) / 0.1);
|
||||
}
|
||||
|
||||
.surface-card:hover {
|
||||
@apply border-border-hover bg-elevated/90;
|
||||
box-shadow: 0 2px 8px hsl(220 14% 50% / 0.08),
|
||||
0 1px 3px hsl(220 14% 50% / 0.05);
|
||||
box-shadow: 0 2px 8px hsl(var(--text-muted) / 0.14),
|
||||
0 1px 3px hsl(var(--text-muted) / 0.1);
|
||||
}
|
||||
|
||||
.input-cursor {
|
||||
@@ -521,16 +559,16 @@ body {
|
||||
@apply transition-all duration-150;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(224 64% 48%) 0%,
|
||||
hsl(232 58% 52%) 100%
|
||||
hsl(var(--accent)) 0%,
|
||||
hsl(var(--accent-hover)) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.btn-primary-solid:hover {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(224 64% 42%) 0%,
|
||||
hsl(232 58% 46%) 100%
|
||||
hsl(var(--accent-hover)) 0%,
|
||||
hsl(var(--accent)) 100%
|
||||
);
|
||||
}
|
||||
|
||||
@@ -572,12 +610,12 @@ body {
|
||||
@apply bg-elevated/70 backdrop-blur-sm;
|
||||
@apply border border-border/60 rounded-2xl;
|
||||
@apply transition-all duration-200;
|
||||
box-shadow: 0 1px 3px hsl(220 14% 50% / 0.05);
|
||||
box-shadow: 0 1px 3px hsl(var(--text-muted) / 0.08);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
@apply border-border bg-elevated/90;
|
||||
box-shadow: 0 2px 8px hsl(220 14% 50% / 0.08);
|
||||
box-shadow: 0 2px 8px hsl(var(--text-muted) / 0.12);
|
||||
}
|
||||
|
||||
.card-interactive {
|
||||
@@ -586,8 +624,8 @@ body {
|
||||
|
||||
.card-interactive:hover {
|
||||
@apply border-accent/25 shadow-lg;
|
||||
box-shadow: 0 4px 16px hsl(224 64% 48% / 0.08),
|
||||
0 1px 4px hsl(220 14% 50% / 0.05);
|
||||
box-shadow: 0 4px 16px hsl(var(--accent) / 0.08),
|
||||
0 1px 4px hsl(var(--text-muted) / 0.1);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
@@ -626,17 +664,17 @@ body {
|
||||
}
|
||||
|
||||
.glow-accent {
|
||||
box-shadow: 0 2px 12px hsl(224 64% 48% / 0.1),
|
||||
0 1px 4px hsl(224 64% 48% / 0.06);
|
||||
box-shadow: 0 2px 12px hsl(var(--accent) / 0.1),
|
||||
0 1px 4px hsl(var(--accent) / 0.06);
|
||||
}
|
||||
|
||||
.glow-accent-strong {
|
||||
box-shadow: 0 4px 20px hsl(224 64% 48% / 0.15),
|
||||
0 2px 8px hsl(224 64% 48% / 0.1);
|
||||
box-shadow: 0 4px 20px hsl(var(--accent) / 0.15),
|
||||
0 2px 8px hsl(var(--accent) / 0.1);
|
||||
}
|
||||
|
||||
.glow-subtle {
|
||||
box-shadow: 0 2px 12px hsl(220 14% 50% / 0.08);
|
||||
box-shadow: 0 2px 12px hsl(var(--text-muted) / 0.12);
|
||||
}
|
||||
|
||||
.focus-ring {
|
||||
@@ -693,10 +731,10 @@ body {
|
||||
|
||||
@keyframes glow-pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 2px 12px hsl(224 64% 48% / 0.08);
|
||||
box-shadow: 0 2px 12px hsl(var(--accent) / 0.08);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 4px 20px hsl(224 64% 48% / 0.14);
|
||||
box-shadow: 0 4px 20px hsl(var(--accent) / 0.14);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -727,9 +765,9 @@ body {
|
||||
.animate-shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
hsl(220 14% 93%) 0%,
|
||||
hsl(220 14% 88%) 50%,
|
||||
hsl(220 14% 93%) 100%
|
||||
hsl(var(--bg-surface)) 0%,
|
||||
hsl(var(--bg-muted)) 50%,
|
||||
hsl(var(--bg-surface)) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s linear infinite;
|
||||
@@ -808,10 +846,10 @@ body {
|
||||
|
||||
@keyframes thinking-pulse {
|
||||
0%, 100% {
|
||||
background: hsl(224 64% 48% / 0.04);
|
||||
background: hsl(var(--accent) / 0.04);
|
||||
}
|
||||
50% {
|
||||
background: hsl(224 64% 48% / 0.09);
|
||||
background: hsl(var(--accent) / 0.09);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -840,7 +878,7 @@ body {
|
||||
|
||||
.file-card-hover:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px -5px hsl(220 14% 50% / 0.15);
|
||||
box-shadow: 0 8px 25px -5px hsl(var(--text-muted) / 0.2);
|
||||
}
|
||||
|
||||
.collapsible-content {
|
||||
@@ -856,11 +894,11 @@ body {
|
||||
.progress-shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
hsl(224 64% 48%) 0%,
|
||||
hsl(240 55% 56%) 25%,
|
||||
hsl(180 50% 42%) 50%,
|
||||
hsl(240 55% 56%) 75%,
|
||||
hsl(224 64% 48%) 100%
|
||||
hsl(var(--accent)) 0%,
|
||||
hsl(var(--accent-hover)) 25%,
|
||||
hsl(var(--accent-secondary)) 50%,
|
||||
hsl(var(--accent-hover)) 75%,
|
||||
hsl(var(--accent)) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: progress-shimmer 2s linear infinite;
|
||||
@@ -886,6 +924,6 @@ body {
|
||||
|
||||
.artifact-glow:hover {
|
||||
box-shadow:
|
||||
0 4px 16px hsl(152 60% 38% / 0.1),
|
||||
0 2px 8px hsl(220 14% 50% / 0.08);
|
||||
0 4px 16px hsl(var(--success) / 0.14),
|
||||
0 2px 8px hsl(var(--text-muted) / 0.12);
|
||||
}
|
||||
|
||||
@@ -7,13 +7,30 @@ export const metadata: Metadata = {
|
||||
description: 'AI-поиск нового поколения',
|
||||
};
|
||||
|
||||
const themeInitScript = `
|
||||
(() => {
|
||||
try {
|
||||
const KEY = 'gooseek_theme';
|
||||
const saved = localStorage.getItem(KEY);
|
||||
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const theme = (saved === 'light' || saved === 'dim') ? saved : (prefersDark ? 'dim' : 'light');
|
||||
const root = document.documentElement;
|
||||
if (theme === 'dim') root.classList.add('theme-dim');
|
||||
else root.classList.remove('theme-dim');
|
||||
} catch {}
|
||||
})();
|
||||
`;
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="ru">
|
||||
<html lang="ru" suppressHydrationWarning>
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
|
||||
</head>
|
||||
<body className="antialiased">
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
|
||||
@@ -3,15 +3,18 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { LanguageProvider } from '@/lib/contexts/LanguageContext';
|
||||
import { AuthProvider } from '@/lib/contexts/AuthContext';
|
||||
import { ThemeProvider } from '@/lib/contexts/ThemeContext';
|
||||
import { AuthModal } from '@/components/auth';
|
||||
|
||||
export function Providers({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<LanguageProvider>
|
||||
{children}
|
||||
<AuthModal />
|
||||
</LanguageProvider>
|
||||
</AuthProvider>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<LanguageProvider>
|
||||
{children}
|
||||
<AuthModal />
|
||||
</LanguageProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
40
backend/webui/src/app/robots.ts
Normal file
40
backend/webui/src/app/robots.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { MetadataRoute } from 'next';
|
||||
|
||||
const ALL_APP_ROUTES = [
|
||||
'/discover',
|
||||
'/spaces',
|
||||
'/history',
|
||||
'/travel',
|
||||
'/medicine',
|
||||
'/finance',
|
||||
'/learning',
|
||||
];
|
||||
|
||||
function getDisallowedRoutes(): string[] {
|
||||
const raw = process.env.NEXT_PUBLIC_ENABLED_ROUTES || '';
|
||||
if (!raw.trim()) return [];
|
||||
|
||||
const enabled = new Set(
|
||||
raw
|
||||
.split(',')
|
||||
.map((r) => r.trim())
|
||||
.filter((r) => r.startsWith('/')),
|
||||
);
|
||||
|
||||
return ALL_APP_ROUTES.filter((r) => !enabled.has(r));
|
||||
}
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
const disallowed = getDisallowedRoutes();
|
||||
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
disallow: ['/api/', '/admin/', ...disallowed.map((r) => r + '/')],
|
||||
},
|
||||
],
|
||||
sitemap: 'https://gooseek.ru/sitemap.xml',
|
||||
};
|
||||
}
|
||||
40
backend/webui/src/app/sitemap.ts
Normal file
40
backend/webui/src/app/sitemap.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { MetadataRoute } from 'next';
|
||||
|
||||
const BASE_URL = 'https://gooseek.ru';
|
||||
|
||||
const ALL_APP_ROUTES = [
|
||||
'/discover',
|
||||
'/spaces',
|
||||
'/history',
|
||||
'/travel',
|
||||
'/medicine',
|
||||
'/finance',
|
||||
'/learning',
|
||||
];
|
||||
|
||||
const ALWAYS_IN_SITEMAP = ['/', '/login', '/register'];
|
||||
|
||||
function getEnabledAppRoutes(): string[] {
|
||||
const raw = process.env.NEXT_PUBLIC_ENABLED_ROUTES || '';
|
||||
if (!raw.trim()) return ALL_APP_ROUTES;
|
||||
|
||||
const enabled = new Set(
|
||||
raw
|
||||
.split(',')
|
||||
.map((r) => r.trim())
|
||||
.filter((r) => r.startsWith('/')),
|
||||
);
|
||||
|
||||
return ALL_APP_ROUTES.filter((r) => enabled.has(r));
|
||||
}
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const routes = [...ALWAYS_IN_SITEMAP, ...getEnabledAppRoutes()];
|
||||
|
||||
return routes.map((route) => ({
|
||||
url: `${BASE_URL}${route}`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: route === '/' ? 'daily' : 'weekly',
|
||||
priority: route === '/' ? 1.0 : 0.7,
|
||||
}));
|
||||
}
|
||||
954
backend/webui/src/components/EditableItinerary.tsx
Normal file
954
backend/webui/src/components/EditableItinerary.tsx
Normal file
@@ -0,0 +1,954 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragOverlay,
|
||||
useDroppable,
|
||||
type DragStartEvent,
|
||||
type DragEndEvent,
|
||||
type DragOverEvent,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
useSortable,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import {
|
||||
GripVertical,
|
||||
X,
|
||||
Plus,
|
||||
Check,
|
||||
RotateCcw,
|
||||
Loader2,
|
||||
AlertTriangle,
|
||||
Lightbulb,
|
||||
Search,
|
||||
MapPin,
|
||||
Ticket,
|
||||
Utensils,
|
||||
Camera,
|
||||
ShoppingBag,
|
||||
Coffee,
|
||||
Footprints,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Clock,
|
||||
Ban,
|
||||
} from 'lucide-react';
|
||||
import type {
|
||||
ItineraryItem,
|
||||
POICard,
|
||||
EventCard,
|
||||
DailyForecast,
|
||||
} from '@/lib/types';
|
||||
import {
|
||||
useEditableItinerary,
|
||||
validateItemPlacement,
|
||||
type EditableDay,
|
||||
type ValidationResult,
|
||||
type LLMValidationResponse,
|
||||
type LLMValidationWarning,
|
||||
type CustomItemType,
|
||||
} from '@/lib/hooks/useEditableItinerary';
|
||||
import type { ItineraryDay } from '@/lib/types';
|
||||
|
||||
// --- Sortable Item ---
|
||||
|
||||
interface SortableItemProps {
|
||||
item: ItineraryItem;
|
||||
itemId: string;
|
||||
dayIdx: number;
|
||||
itemIdx: number;
|
||||
onRemove: () => void;
|
||||
warning?: LLMValidationWarning;
|
||||
}
|
||||
|
||||
function SortableItem({ item, itemId, dayIdx, itemIdx, onRemove, warning }: SortableItemProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: itemId });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`group flex items-center gap-2 px-3 py-2 rounded-lg border transition-colors ${
|
||||
warning
|
||||
? 'border-amber-500/40 bg-amber-500/5'
|
||||
: 'border-border/30 bg-elevated/20 hover:border-border/50'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="flex-shrink-0 p-0.5 text-muted hover:text-secondary cursor-grab active:cursor-grabbing touch-none"
|
||||
aria-label="Перетащить"
|
||||
>
|
||||
<GripVertical className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{item.startTime && (
|
||||
<span className="text-[10px] text-accent font-mono bg-accent/8 px-1.5 py-px rounded flex-shrink-0">
|
||||
{item.startTime}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[12px] font-medium text-primary truncate">{item.title}</span>
|
||||
<span className="text-[9px] text-muted bg-surface/50 px-1.5 py-px rounded-full flex-shrink-0">
|
||||
{item.refType}
|
||||
</span>
|
||||
</div>
|
||||
{warning && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<AlertTriangle className="w-3 h-3 text-amber-400 flex-shrink-0" />
|
||||
<span className="text-[10px] text-amber-400">{warning.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="flex-shrink-0 p-1 rounded text-muted hover:text-error hover:bg-error/10 opacity-0 group-hover:opacity-100 transition-all"
|
||||
aria-label="Удалить"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Drag Overlay Item ---
|
||||
|
||||
function DragOverlayItem({ item }: { item: ItineraryItem }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg border border-accent/50 bg-elevated shadow-lg shadow-accent/10">
|
||||
<GripVertical className="w-4 h-4 text-accent" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{item.startTime && (
|
||||
<span className="text-[10px] text-accent font-mono bg-accent/8 px-1.5 py-px rounded">
|
||||
{item.startTime}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[12px] font-medium text-primary truncate">{item.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Validation Toast ---
|
||||
|
||||
function ValidationToast({ result, onDismiss }: { result: ValidationResult; onDismiss: () => void }) {
|
||||
if (result.valid) return null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
className="fixed bottom-24 left-1/2 -translate-x-1/2 z-50 max-w-sm"
|
||||
>
|
||||
<div className="flex items-center gap-3 px-4 py-3 rounded-xl bg-red-950/90 border border-red-500/40 shadow-lg backdrop-blur-sm">
|
||||
<Ban className="w-5 h-5 text-red-400 flex-shrink-0" />
|
||||
<p className="text-sm text-red-200">{result.reason}</p>
|
||||
<button onClick={onDismiss} className="p-1 text-red-400 hover:text-red-300 flex-shrink-0">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Add Item Panel ---
|
||||
|
||||
interface AddItemPanelProps {
|
||||
pois: POICard[];
|
||||
events: EventCard[];
|
||||
dayIdx: number;
|
||||
dayDate: string;
|
||||
dayItems: ItineraryItem[];
|
||||
poisMap: Map<string, POICard>;
|
||||
eventsMap: Map<string, EventCard>;
|
||||
onAdd: (dayIdx: number, item: ItineraryItem) => ValidationResult;
|
||||
onAddCustom: (dayIdx: number, title: string, refType: CustomItemType, duration: number) => ValidationResult;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type AddTab = 'poi' | 'event' | 'custom';
|
||||
|
||||
const customTypeOptions: { value: CustomItemType; label: string; icon: typeof Utensils }[] = [
|
||||
{ value: 'food', label: 'Еда', icon: Utensils },
|
||||
{ value: 'walk', label: 'Прогулка', icon: Footprints },
|
||||
{ value: 'rest', label: 'Отдых', icon: Coffee },
|
||||
{ value: 'shopping', label: 'Шопинг', icon: ShoppingBag },
|
||||
{ value: 'custom', label: 'Другое', icon: MapPin },
|
||||
];
|
||||
|
||||
function AddItemPanel({
|
||||
pois,
|
||||
events,
|
||||
dayIdx,
|
||||
dayDate,
|
||||
dayItems,
|
||||
poisMap,
|
||||
eventsMap,
|
||||
onAdd,
|
||||
onAddCustom,
|
||||
onClose,
|
||||
}: AddItemPanelProps) {
|
||||
const [tab, setTab] = useState<AddTab>('poi');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState<POICategory>('all');
|
||||
const [customTitle, setCustomTitle] = useState('');
|
||||
const [customType, setCustomType] = useState<CustomItemType>('food');
|
||||
const [customDuration, setCustomDuration] = useState(60);
|
||||
const [lastError, setLastError] = useState<string | null>(null);
|
||||
|
||||
const filteredPois = useMemo(() => {
|
||||
let result = pois;
|
||||
if (categoryFilter !== 'all') {
|
||||
result = result.filter((p) => p.category.toLowerCase().includes(categoryFilter));
|
||||
}
|
||||
if (searchQuery.trim()) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
result = result.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
p.category.toLowerCase().includes(q) ||
|
||||
(p.address && p.address.toLowerCase().includes(q)),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}, [pois, searchQuery, categoryFilter]);
|
||||
|
||||
const filteredEvents = useMemo(() => {
|
||||
if (!searchQuery.trim()) return events;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return events.filter(
|
||||
(e) =>
|
||||
e.title.toLowerCase().includes(q) ||
|
||||
(e.address && e.address.toLowerCase().includes(q)),
|
||||
);
|
||||
}, [events, searchQuery]);
|
||||
|
||||
const handleAddPOI = useCallback(
|
||||
(poi: POICard) => {
|
||||
const item: ItineraryItem = {
|
||||
refType: 'poi',
|
||||
refId: poi.id,
|
||||
title: poi.name,
|
||||
lat: poi.lat,
|
||||
lng: poi.lng,
|
||||
note: poi.description || '',
|
||||
cost: poi.price || 0,
|
||||
currency: poi.currency || 'RUB',
|
||||
};
|
||||
const result = onAdd(dayIdx, item);
|
||||
if (!result.valid) {
|
||||
setLastError(result.reason || 'Невозможно добавить');
|
||||
setTimeout(() => setLastError(null), 3000);
|
||||
} else {
|
||||
setLastError(null);
|
||||
}
|
||||
},
|
||||
[dayIdx, onAdd],
|
||||
);
|
||||
|
||||
const handleAddEvent = useCallback(
|
||||
(event: EventCard) => {
|
||||
const item: ItineraryItem = {
|
||||
refType: 'event',
|
||||
refId: event.id,
|
||||
title: event.title,
|
||||
lat: event.lat || 0,
|
||||
lng: event.lng || 0,
|
||||
note: event.description || '',
|
||||
cost: event.price || 0,
|
||||
currency: event.currency || 'RUB',
|
||||
};
|
||||
const result = onAdd(dayIdx, item);
|
||||
if (!result.valid) {
|
||||
setLastError(result.reason || 'Невозможно добавить');
|
||||
setTimeout(() => setLastError(null), 3000);
|
||||
} else {
|
||||
setLastError(null);
|
||||
}
|
||||
},
|
||||
[dayIdx, onAdd],
|
||||
);
|
||||
|
||||
const handleAddCustom = useCallback(() => {
|
||||
if (!customTitle.trim()) return;
|
||||
const result = onAddCustom(dayIdx, customTitle.trim(), customType, customDuration);
|
||||
if (!result.valid) {
|
||||
setLastError(result.reason || 'Невозможно добавить');
|
||||
setTimeout(() => setLastError(null), 3000);
|
||||
} else {
|
||||
setCustomTitle('');
|
||||
setLastError(null);
|
||||
}
|
||||
}, [dayIdx, customTitle, customType, customDuration, onAddCustom]);
|
||||
|
||||
const isItemAlreadyInDay = useCallback(
|
||||
(refId: string) => dayItems.some((i) => i.refId === refId),
|
||||
[dayItems],
|
||||
);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="p-3 border border-border/30 rounded-lg bg-elevated/30 mt-2">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex gap-1">
|
||||
{[
|
||||
{ key: 'poi' as AddTab, label: 'Места', icon: Camera },
|
||||
{ key: 'event' as AddTab, label: 'События', icon: Ticket },
|
||||
{ key: 'custom' as AddTab, label: 'Своё', icon: Plus },
|
||||
].map(({ key, label, icon: Icon }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => { setTab(key); setSearchQuery(''); }}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-[11px] rounded-lg transition-colors ${
|
||||
tab === key
|
||||
? 'bg-accent/20 text-accent'
|
||||
: 'text-muted hover:text-secondary hover:bg-surface/30'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3 h-3" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 text-muted hover:text-primary">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{lastError && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 mb-2 rounded-lg bg-red-500/10 border border-red-500/20">
|
||||
<Ban className="w-3.5 h-3.5 text-red-400 flex-shrink-0" />
|
||||
<span className="text-[11px] text-red-300">{lastError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab !== 'custom' && (
|
||||
<div className="space-y-2 mb-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={tab === 'poi' ? 'Поиск мест...' : 'Поиск событий...'}
|
||||
className="w-full pl-8 pr-3 py-2 bg-surface/40 border border-border/30 rounded-lg text-[12px] text-primary placeholder:text-muted focus:outline-none focus:border-accent/40"
|
||||
/>
|
||||
</div>
|
||||
{tab === 'poi' && (
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{categoryFilters.map((cf) => (
|
||||
<button
|
||||
key={cf.value}
|
||||
onClick={() => setCategoryFilter(cf.value)}
|
||||
className={`px-2 py-1 text-[9px] rounded-md transition-colors ${
|
||||
categoryFilter === cf.value
|
||||
? 'bg-accent/20 text-accent'
|
||||
: 'bg-surface/30 text-muted hover:text-secondary'
|
||||
}`}
|
||||
>
|
||||
{cf.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'poi' && (
|
||||
<div className="max-h-[200px] overflow-y-auto space-y-1 scrollbar-thin">
|
||||
{filteredPois.length === 0 ? (
|
||||
<p className="text-[11px] text-muted text-center py-4">Нет доступных мест</p>
|
||||
) : (
|
||||
filteredPois.map((poi) => {
|
||||
const alreadyAdded = isItemAlreadyInDay(poi.id);
|
||||
const precheck = validateItemPlacement(
|
||||
{ refType: 'poi', refId: poi.id, title: poi.name, lat: poi.lat, lng: poi.lng },
|
||||
dayDate,
|
||||
dayItems,
|
||||
poisMap,
|
||||
eventsMap,
|
||||
);
|
||||
return (
|
||||
<button
|
||||
key={poi.id}
|
||||
onClick={() => !alreadyAdded && precheck.valid && handleAddPOI(poi)}
|
||||
disabled={alreadyAdded || !precheck.valid}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left transition-colors ${
|
||||
alreadyAdded
|
||||
? 'opacity-50 cursor-not-allowed bg-surface/20'
|
||||
: !precheck.valid
|
||||
? 'opacity-60 cursor-not-allowed bg-red-500/5'
|
||||
: 'hover:bg-surface/30'
|
||||
}`}
|
||||
>
|
||||
<Camera className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-[11px] font-medium text-primary truncate block">{poi.name}</span>
|
||||
{!precheck.valid && (
|
||||
<span className="text-[9px] text-red-400">{precheck.reason}</span>
|
||||
)}
|
||||
</div>
|
||||
{alreadyAdded ? (
|
||||
<Check className="w-3.5 h-3.5 text-green-400 flex-shrink-0" />
|
||||
) : (
|
||||
<Plus className="w-3.5 h-3.5 text-muted flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'event' && (
|
||||
<div className="max-h-[200px] overflow-y-auto space-y-1 scrollbar-thin">
|
||||
{filteredEvents.length === 0 ? (
|
||||
<p className="text-[11px] text-muted text-center py-4">Нет доступных событий</p>
|
||||
) : (
|
||||
filteredEvents.map((event) => {
|
||||
const alreadyAdded = isItemAlreadyInDay(event.id);
|
||||
const precheck = validateItemPlacement(
|
||||
{ refType: 'event', refId: event.id, title: event.title, lat: event.lat || 0, lng: event.lng || 0 },
|
||||
dayDate,
|
||||
dayItems,
|
||||
poisMap,
|
||||
eventsMap,
|
||||
);
|
||||
return (
|
||||
<button
|
||||
key={event.id}
|
||||
onClick={() => !alreadyAdded && precheck.valid && handleAddEvent(event)}
|
||||
disabled={alreadyAdded || !precheck.valid}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left transition-colors ${
|
||||
alreadyAdded
|
||||
? 'opacity-50 cursor-not-allowed bg-surface/20'
|
||||
: !precheck.valid
|
||||
? 'opacity-60 cursor-not-allowed bg-red-500/5'
|
||||
: 'hover:bg-surface/30'
|
||||
}`}
|
||||
>
|
||||
<Ticket className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-[11px] font-medium text-primary truncate block">{event.title}</span>
|
||||
{event.dateStart && (
|
||||
<span className="text-[9px] text-muted">{event.dateStart}</span>
|
||||
)}
|
||||
{!precheck.valid && (
|
||||
<span className="text-[9px] text-red-400 block">{precheck.reason}</span>
|
||||
)}
|
||||
</div>
|
||||
{alreadyAdded ? (
|
||||
<Check className="w-3.5 h-3.5 text-green-400 flex-shrink-0" />
|
||||
) : (
|
||||
<Plus className="w-3.5 h-3.5 text-muted flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'custom' && (
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
value={customTitle}
|
||||
onChange={(e) => setCustomTitle(e.target.value)}
|
||||
placeholder="Название пункта..."
|
||||
className="w-full px-3 py-2 bg-surface/40 border border-border/30 rounded-lg text-[12px] text-primary placeholder:text-muted focus:outline-none focus:border-accent/40"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{customTypeOptions.map(({ value, label, icon: Icon }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setCustomType(value)}
|
||||
className={`flex items-center gap-1 px-2.5 py-1.5 text-[10px] rounded-lg transition-colors ${
|
||||
customType === value
|
||||
? 'bg-accent/20 text-accent border border-accent/30'
|
||||
: 'bg-surface/30 text-muted hover:text-secondary border border-border/20'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3 h-3" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-3.5 h-3.5 text-muted" />
|
||||
<input
|
||||
type="number"
|
||||
value={customDuration}
|
||||
onChange={(e) => setCustomDuration(Math.max(15, Number(e.target.value)))}
|
||||
min={15}
|
||||
max={480}
|
||||
step={15}
|
||||
className="w-20 px-2 py-1.5 bg-surface/40 border border-border/30 rounded-lg text-[11px] text-primary focus:outline-none focus:border-accent/40"
|
||||
/>
|
||||
<span className="text-[10px] text-muted">мин</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddCustom}
|
||||
disabled={!customTitle.trim()}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-accent/20 text-accent rounded-lg text-[11px] font-medium hover:bg-accent/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Droppable Day Container ---
|
||||
|
||||
function DroppableDayZone({ dayIdx, children }: { dayIdx: number; children: React.ReactNode }) {
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: `droppable-day-${dayIdx}`,
|
||||
data: { dayIdx },
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`transition-colors rounded-lg ${isOver ? 'ring-2 ring-accent/40 bg-accent/5' : ''}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Category filter for AddItemPanel ---
|
||||
|
||||
type POICategory = 'all' | 'attraction' | 'museum' | 'park' | 'restaurant' | 'shopping' | 'entertainment' | 'religious';
|
||||
|
||||
const categoryFilters: { value: POICategory; label: string }[] = [
|
||||
{ value: 'all', label: 'Все' },
|
||||
{ value: 'attraction', label: 'Достопримечательности' },
|
||||
{ value: 'museum', label: 'Музеи' },
|
||||
{ value: 'park', label: 'Парки' },
|
||||
{ value: 'restaurant', label: 'Рестораны' },
|
||||
{ value: 'shopping', label: 'Шопинг' },
|
||||
{ value: 'entertainment', label: 'Развлечения' },
|
||||
];
|
||||
|
||||
// --- Main EditableItinerary Component ---
|
||||
|
||||
interface EditableItineraryProps {
|
||||
days: ItineraryDay[];
|
||||
pois: POICard[];
|
||||
events: EventCard[];
|
||||
dailyForecast?: DailyForecast[];
|
||||
onApply: (days: EditableDay[]) => void;
|
||||
onCancel: () => void;
|
||||
onValidateWithLLM?: (days: EditableDay[]) => Promise<LLMValidationResponse | null>;
|
||||
}
|
||||
|
||||
export function EditableItinerary({
|
||||
days,
|
||||
pois,
|
||||
events,
|
||||
dailyForecast,
|
||||
onApply,
|
||||
onCancel,
|
||||
onValidateWithLLM,
|
||||
}: EditableItineraryProps) {
|
||||
const poisMap = useMemo(() => new Map(pois.map((p) => [p.id, p])), [pois]);
|
||||
const eventsMap = useMemo(() => new Map(events.map((e) => [e.id, e])), [events]);
|
||||
|
||||
const {
|
||||
editableDays,
|
||||
isEditing,
|
||||
hasChanges,
|
||||
isValidating,
|
||||
llmWarnings,
|
||||
llmSuggestions,
|
||||
startEditing,
|
||||
stopEditing,
|
||||
resetChanges,
|
||||
moveItem,
|
||||
addItem,
|
||||
removeItem,
|
||||
addCustomItem,
|
||||
applyChanges,
|
||||
} = useEditableItinerary({
|
||||
initialDays: days,
|
||||
poisMap,
|
||||
eventsMap,
|
||||
dailyForecast,
|
||||
onValidateWithLLM,
|
||||
});
|
||||
|
||||
const [activeItem, setActiveItem] = useState<ItineraryItem | null>(null);
|
||||
const [activeDragId, setActiveDragId] = useState<string | null>(null);
|
||||
const [validationToast, setValidationToast] = useState<ValidationResult | null>(null);
|
||||
const [addPanelDayIdx, setAddPanelDayIdx] = useState<number | null>(null);
|
||||
const [expandedDays, setExpandedDays] = useState<Set<number>>(new Set([0]));
|
||||
const [showLLMResults, setShowLLMResults] = useState(false);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
|
||||
// Start editing on mount
|
||||
if (!isEditing) {
|
||||
startEditing();
|
||||
}
|
||||
|
||||
const getItemId = (dayIdx: number, itemIdx: number): string =>
|
||||
`day-${dayIdx}-item-${itemIdx}`;
|
||||
|
||||
const parseItemId = (id: string): { dayIdx: number; itemIdx: number } | null => {
|
||||
const match = id.match(/^day-(\d+)-item-(\d+)$/);
|
||||
if (!match) return null;
|
||||
return { dayIdx: parseInt(match[1], 10), itemIdx: parseInt(match[2], 10) };
|
||||
};
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
const parsed = parseItemId(String(event.active.id));
|
||||
if (!parsed) return;
|
||||
const item = editableDays[parsed.dayIdx]?.items[parsed.itemIdx];
|
||||
if (item) {
|
||||
setActiveItem(item);
|
||||
setActiveDragId(String(event.active.id));
|
||||
}
|
||||
},
|
||||
[editableDays],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(event: DragOverEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
|
||||
const from = parseItemId(String(active.id));
|
||||
if (!from) return;
|
||||
|
||||
const overId = String(over.id);
|
||||
let toDayIdx: number | null = null;
|
||||
|
||||
if (overId.startsWith('droppable-day-')) {
|
||||
toDayIdx = parseInt(overId.replace('droppable-day-', ''), 10);
|
||||
} else {
|
||||
const to = parseItemId(overId);
|
||||
if (to) toDayIdx = to.dayIdx;
|
||||
}
|
||||
|
||||
if (toDayIdx !== null && toDayIdx !== from.dayIdx) {
|
||||
const toItemIdx = editableDays[toDayIdx]?.items.length ?? 0;
|
||||
const result = moveItem(from.dayIdx, from.itemIdx, toDayIdx, toItemIdx);
|
||||
if (!result.valid) {
|
||||
setValidationToast(result);
|
||||
setTimeout(() => setValidationToast(null), 4000);
|
||||
}
|
||||
}
|
||||
},
|
||||
[editableDays, moveItem],
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
setActiveItem(null);
|
||||
setActiveDragId(null);
|
||||
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const from = parseItemId(String(active.id));
|
||||
const to = parseItemId(String(over.id));
|
||||
if (!from || !to) return;
|
||||
|
||||
if (from.dayIdx === to.dayIdx) {
|
||||
const result = moveItem(from.dayIdx, from.itemIdx, to.dayIdx, to.itemIdx);
|
||||
if (!result.valid) {
|
||||
setValidationToast(result);
|
||||
setTimeout(() => setValidationToast(null), 4000);
|
||||
}
|
||||
}
|
||||
},
|
||||
[moveItem],
|
||||
);
|
||||
|
||||
const handleApply = useCallback(async () => {
|
||||
if (onValidateWithLLM) {
|
||||
const result = await applyChanges();
|
||||
if (result) {
|
||||
setShowLLMResults(true);
|
||||
const hasCritical = result.warnings.length > 0;
|
||||
if (!hasCritical) {
|
||||
onApply(editableDays);
|
||||
}
|
||||
} else {
|
||||
onApply(editableDays);
|
||||
}
|
||||
} else {
|
||||
onApply(editableDays);
|
||||
}
|
||||
}, [applyChanges, editableDays, onApply, onValidateWithLLM]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
stopEditing();
|
||||
onCancel();
|
||||
}, [stopEditing, onCancel]);
|
||||
|
||||
const toggleDay = useCallback((idx: number) => {
|
||||
setExpandedDays((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(idx)) next.delete(idx);
|
||||
else next.add(idx);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const getWarningForItem = useCallback(
|
||||
(dayIdx: number, itemIdx: number): LLMValidationWarning | undefined =>
|
||||
llmWarnings.find((w) => w.dayIdx === dayIdx && w.itemIdx === itemIdx),
|
||||
[llmWarnings],
|
||||
);
|
||||
|
||||
if (!isEditing || editableDays.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between gap-2 px-1 pb-2 border-b border-border/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleApply}
|
||||
disabled={!hasChanges || isValidating}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-medium bg-accent/20 text-accent rounded-lg hover:bg-accent/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isValidating ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{isValidating ? 'Проверка...' : 'Применить'}
|
||||
</button>
|
||||
<button
|
||||
onClick={resetChanges}
|
||||
disabled={!hasChanges}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-[11px] text-muted hover:text-secondary rounded-lg hover:bg-surface/30 transition-colors disabled:opacity-40"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
Сбросить
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-[11px] text-muted hover:text-primary rounded-lg hover:bg-surface/30 transition-colors"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* LLM Validation Results */}
|
||||
<AnimatePresence>
|
||||
{showLLMResults && (llmWarnings.length > 0 || llmSuggestions.length > 0) && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="space-y-2 pb-2">
|
||||
{llmWarnings.length > 0 && (
|
||||
<div className="p-3 rounded-lg bg-amber-500/10 border border-amber-500/20">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-400" />
|
||||
<span className="text-[11px] font-medium text-amber-300">Предупреждения</span>
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{llmWarnings.map((w, i) => (
|
||||
<li key={i} className="text-[10px] text-amber-200/80 flex items-start gap-1.5">
|
||||
<span className="text-amber-400 flex-shrink-0">•</span>
|
||||
{w.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button
|
||||
onClick={() => { onApply(editableDays); setShowLLMResults(false); }}
|
||||
className="mt-2 text-[10px] text-accent hover:underline"
|
||||
>
|
||||
Применить всё равно
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{llmSuggestions.length > 0 && (
|
||||
<div className="p-3 rounded-lg bg-accent/5 border border-accent/20">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Lightbulb className="w-4 h-4 text-accent" />
|
||||
<span className="text-[11px] font-medium text-accent">Рекомендации</span>
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{llmSuggestions.map((s, i) => (
|
||||
<li key={i} className="text-[10px] text-secondary flex items-start gap-1.5">
|
||||
<span className="text-accent flex-shrink-0">•</span>
|
||||
{s.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* DnD Context */}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{editableDays.map((day, dayIdx) => {
|
||||
const isExpanded = expandedDays.has(dayIdx);
|
||||
const itemIds = day.items.map((_, i) => getItemId(dayIdx, i));
|
||||
|
||||
return (
|
||||
<DroppableDayZone key={`edit-day-${dayIdx}`} dayIdx={dayIdx}>
|
||||
<div
|
||||
className="border border-border/30 rounded-xl overflow-hidden bg-elevated/20"
|
||||
>
|
||||
<button
|
||||
onClick={() => toggleDay(dayIdx)}
|
||||
className="w-full flex items-center justify-between px-3.5 py-2.5 hover:bg-surface/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="w-6 h-6 rounded-md bg-accent text-white text-[10px] font-bold flex items-center justify-center">
|
||||
{dayIdx + 1}
|
||||
</span>
|
||||
<span className="text-[13px] font-medium text-primary">{day.date}</span>
|
||||
<span className="text-[11px] text-muted">{day.items.length} мест</span>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-4 h-4 text-muted" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4 text-muted" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="px-3.5 pb-3 pt-1 space-y-1.5">
|
||||
<SortableContext items={itemIds} strategy={verticalListSortingStrategy}>
|
||||
{day.items.map((item, itemIdx) => (
|
||||
<SortableItem
|
||||
key={getItemId(dayIdx, itemIdx)}
|
||||
item={item}
|
||||
itemId={getItemId(dayIdx, itemIdx)}
|
||||
dayIdx={dayIdx}
|
||||
itemIdx={itemIdx}
|
||||
onRemove={() => removeItem(dayIdx, itemIdx)}
|
||||
warning={getWarningForItem(dayIdx, itemIdx)}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
|
||||
{day.items.length === 0 && (
|
||||
<div className="py-4 text-center text-[11px] text-muted">
|
||||
Пусто — добавьте пункты
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() =>
|
||||
setAddPanelDayIdx(addPanelDayIdx === dayIdx ? null : dayIdx)
|
||||
}
|
||||
className="w-full flex items-center justify-center gap-1.5 px-3 py-2 border border-dashed border-border/40 rounded-lg text-[11px] text-muted hover:text-accent hover:border-accent/30 transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Добавить пункт
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{addPanelDayIdx === dayIdx && (
|
||||
<AddItemPanel
|
||||
pois={pois}
|
||||
events={events}
|
||||
dayIdx={dayIdx}
|
||||
dayDate={day.date}
|
||||
dayItems={day.items}
|
||||
poisMap={poisMap}
|
||||
eventsMap={eventsMap}
|
||||
onAdd={addItem}
|
||||
onAddCustom={addCustomItem}
|
||||
onClose={() => setAddPanelDayIdx(null)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</DroppableDayZone>
|
||||
);
|
||||
})}
|
||||
|
||||
<DragOverlay>
|
||||
{activeItem ? <DragOverlayItem item={activeItem} /> : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
|
||||
{/* Validation Toast */}
|
||||
<AnimatePresence>
|
||||
{validationToast && !validationToast.valid && (
|
||||
<ValidationToast
|
||||
result={validationToast}
|
||||
onDismiss={() => setValidationToast(null)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
668
backend/webui/src/components/MedicineWidgetTabs.tsx
Normal file
668
backend/webui/src/components/MedicineWidgetTabs.tsx
Normal file
@@ -0,0 +1,668 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState, useCallback } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
ArrowUpRight,
|
||||
Beaker,
|
||||
Calendar,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
CircleAlert,
|
||||
ExternalLink,
|
||||
HeartPulse,
|
||||
HelpCircle,
|
||||
Home,
|
||||
Loader2,
|
||||
MapPin,
|
||||
Monitor,
|
||||
Pill,
|
||||
Shield,
|
||||
Stethoscope,
|
||||
Thermometer,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import type { MedicineWidget } from '@/lib/hooks/useMedicineChat';
|
||||
|
||||
type TabId = 'assessment' | 'doctors' | 'booking' | 'reference';
|
||||
|
||||
interface TabDef {
|
||||
id: TabId;
|
||||
label: string;
|
||||
icon: typeof HeartPulse;
|
||||
emptyLabel: string;
|
||||
}
|
||||
|
||||
const TABS: TabDef[] = [
|
||||
{ id: 'assessment', label: 'Оценка', icon: HeartPulse, emptyLabel: 'Оценка симптомов появится после запроса' },
|
||||
{ id: 'doctors', label: 'Врачи', icon: Stethoscope, emptyLabel: 'Специалисты подбираются после анализа' },
|
||||
{ id: 'booking', label: 'Запись', icon: Calendar, emptyLabel: 'Ссылки на запись появятся после подбора врачей' },
|
||||
{ id: 'reference', label: 'Справка', icon: Pill, emptyLabel: 'Справочная информация появится после ответа' },
|
||||
];
|
||||
|
||||
interface ConditionItem {
|
||||
name: string;
|
||||
likelihood: string;
|
||||
why: string;
|
||||
}
|
||||
|
||||
interface SpecialtyItem {
|
||||
specialty: string;
|
||||
reason: string;
|
||||
priority: string;
|
||||
}
|
||||
|
||||
interface DoctorOption {
|
||||
id: string;
|
||||
name: string;
|
||||
specialty: string;
|
||||
clinic: string;
|
||||
city: string;
|
||||
address?: string;
|
||||
sourceUrl: string;
|
||||
sourceName: string;
|
||||
snippet?: string;
|
||||
}
|
||||
|
||||
interface BookingLink {
|
||||
id: string;
|
||||
doctorId: string;
|
||||
doctor: string;
|
||||
specialty: string;
|
||||
clinic: string;
|
||||
bookUrl: string;
|
||||
remote: boolean;
|
||||
}
|
||||
|
||||
interface MedicationInfoItem {
|
||||
name: string;
|
||||
forWhat: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
interface SupplementInfoItem {
|
||||
name: string;
|
||||
purpose: string;
|
||||
evidence: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
interface ProcedureInfoItem {
|
||||
name: string;
|
||||
purpose: string;
|
||||
whenUseful: string;
|
||||
}
|
||||
|
||||
const TRIAGE_CONFIG: Record<string, { label: string; color: string; bg: string; border: string; icon: typeof Zap }> = {
|
||||
low: { label: 'Низкий', color: 'text-green-400', bg: 'bg-green-500/10', border: 'border-green-500/30', icon: Shield },
|
||||
medium: { label: 'Средний', color: 'text-amber-400', bg: 'bg-amber-500/10', border: 'border-amber-500/30', icon: Activity },
|
||||
high: { label: 'Высокий', color: 'text-orange-400', bg: 'bg-orange-500/10', border: 'border-orange-500/30', icon: AlertTriangle },
|
||||
emergency: { label: 'Экстренный', color: 'text-red-400', bg: 'bg-red-500/10', border: 'border-red-500/30', icon: Zap },
|
||||
};
|
||||
|
||||
const LIKELIHOOD_CONFIG: Record<string, { label: string; width: string; color: string }> = {
|
||||
low: { label: 'Маловероятно', width: 'w-1/4', color: 'bg-blue-500/60' },
|
||||
medium: { label: 'Возможно', width: 'w-2/4', color: 'bg-amber-500/60' },
|
||||
high: { label: 'Вероятно', width: 'w-3/4', color: 'bg-orange-500/60' },
|
||||
};
|
||||
|
||||
const EVIDENCE_CONFIG: Record<string, { label: string; color: string; bg: string }> = {
|
||||
low: { label: 'Слабая доказательность', color: 'text-zinc-400', bg: 'bg-zinc-500/15' },
|
||||
medium: { label: 'Умеренная доказательность', color: 'text-amber-400', bg: 'bg-amber-500/15' },
|
||||
high: { label: 'Высокая доказательность', color: 'text-green-400', bg: 'bg-green-500/15' },
|
||||
};
|
||||
|
||||
function CollapsibleSection({
|
||||
title,
|
||||
icon: Icon,
|
||||
children,
|
||||
defaultOpen = true,
|
||||
count,
|
||||
}: {
|
||||
title: string;
|
||||
icon: typeof HeartPulse;
|
||||
children: React.ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
count?: number;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<div className="border border-border/20 rounded-xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="w-full flex items-center justify-between gap-2 px-3 py-2.5 bg-surface/30 hover:bg-surface/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-primary">
|
||||
<Icon className="w-4 h-4 text-accent" />
|
||||
{title}
|
||||
{count !== undefined && count > 0 && (
|
||||
<span className="text-xs text-muted bg-surface/50 px-1.5 py-0.5 rounded-md">{count}</span>
|
||||
)}
|
||||
</div>
|
||||
{open ? <ChevronUp className="w-3.5 h-3.5 text-muted" /> : <ChevronDown className="w-3.5 h-3.5 text-muted" />}
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="p-3 space-y-2">{children}</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TriageBadge({ level }: { level: string }) {
|
||||
const cfg = TRIAGE_CONFIG[level] || TRIAGE_CONFIG.medium;
|
||||
const Icon = cfg.icon;
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-3 p-4 rounded-xl border ${cfg.bg} ${cfg.border}`}>
|
||||
<div className={`w-10 h-10 rounded-xl ${cfg.bg} flex items-center justify-center`}>
|
||||
<Icon className={`w-5 h-5 ${cfg.color}`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted uppercase tracking-wider">Уровень приоритета</div>
|
||||
<div className={`text-lg font-bold ${cfg.color}`}>{cfg.label}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LikelihoodBar({ likelihood }: { likelihood: string }) {
|
||||
const cfg = LIKELIHOOD_CONFIG[likelihood] || LIKELIHOOD_CONFIG.medium;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 bg-surface/40 rounded-full overflow-hidden">
|
||||
<div className={`h-full ${cfg.width} ${cfg.color} rounded-full transition-all`} />
|
||||
</div>
|
||||
<span className="text-xs text-muted whitespace-nowrap">{cfg.label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssessmentTab({ widget }: { widget: MedicineWidget | undefined }) {
|
||||
if (!widget) return null;
|
||||
|
||||
const p = widget.params;
|
||||
const triageLevel = String(p.triageLevel || 'medium');
|
||||
const urgentSigns = (p.urgentSigns || []) as string[];
|
||||
const conditions = (p.possibleConditions || []) as ConditionItem[];
|
||||
const specialists = (p.recommendedSpecialists || []) as SpecialtyItem[];
|
||||
const questions = (p.questionsToClarify || []) as string[];
|
||||
const homeCare = (p.homeCare || []) as string[];
|
||||
const disclaimer = String(p.disclaimer || '');
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<TriageBadge level={triageLevel} />
|
||||
|
||||
{urgentSigns.length > 0 && (
|
||||
<div className="p-3 rounded-xl bg-red-500/5 border border-red-500/20">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<CircleAlert className="w-4 h-4 text-red-400" />
|
||||
<span className="text-sm font-medium text-red-400">Красные флаги</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{urgentSigns.map((sign, i) => (
|
||||
<div key={`us-${i}`} className="flex items-start gap-2 text-xs text-secondary">
|
||||
<span className="text-red-400 mt-0.5">!</span>
|
||||
<span>{sign} — <strong className="text-red-400">вызывайте 103/112</strong></span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{conditions.length > 0 && (
|
||||
<CollapsibleSection title="Вероятные состояния" icon={Thermometer} count={conditions.length}>
|
||||
{conditions.map((c, i) => (
|
||||
<div key={`cond-${i}`} className="p-3 rounded-lg bg-surface/20 border border-border/15 space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="text-sm font-medium text-primary">{c.name}</span>
|
||||
</div>
|
||||
<LikelihoodBar likelihood={c.likelihood} />
|
||||
{c.why && <p className="text-xs text-muted leading-relaxed">{c.why}</p>}
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{specialists.length > 0 && (
|
||||
<CollapsibleSection title="Рекомендуемые специалисты" icon={Stethoscope} count={specialists.length}>
|
||||
{specialists.map((sp, i) => (
|
||||
<div key={`sp-${i}`} className="flex items-start gap-3 p-2.5 rounded-lg bg-surface/20 border border-border/15">
|
||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center shrink-0 ${sp.priority === 'high' ? 'bg-accent/15' : 'bg-surface/40'}`}>
|
||||
<Stethoscope className={`w-4 h-4 ${sp.priority === 'high' ? 'text-accent' : 'text-muted'}`} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-primary">{sp.specialty}</span>
|
||||
{sp.priority === 'high' && (
|
||||
<span className="text-3xs px-1.5 py-0.5 rounded bg-accent/15 text-accent font-medium">Приоритет</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted mt-0.5">{sp.reason}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{questions.length > 0 && (
|
||||
<CollapsibleSection title="Уточняющие вопросы" icon={HelpCircle} count={questions.length} defaultOpen={false}>
|
||||
<p className="text-xs text-muted mb-2">Ответы на эти вопросы помогут уточнить оценку:</p>
|
||||
{questions.map((q, i) => (
|
||||
<div key={`q-${i}`} className="flex items-start gap-2 p-2 rounded-lg bg-surface/20">
|
||||
<HelpCircle className="w-3.5 h-3.5 text-accent mt-0.5 shrink-0" />
|
||||
<span className="text-xs text-secondary">{q}</span>
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{homeCare.length > 0 && (
|
||||
<CollapsibleSection title="Что делать дома до визита" icon={Home} count={homeCare.length} defaultOpen={false}>
|
||||
{homeCare.map((tip, i) => (
|
||||
<div key={`hc-${i}`} className="flex items-start gap-2 p-2 rounded-lg bg-surface/20">
|
||||
<span className="text-green-400 mt-0.5 text-xs">+</span>
|
||||
<span className="text-xs text-secondary">{tip}</span>
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{disclaimer && (
|
||||
<div className="p-3 rounded-xl bg-surface/20 border border-border/15">
|
||||
<p className="text-xs text-muted italic leading-relaxed">{disclaimer}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DoctorsTab({ widget }: { widget: MedicineWidget | undefined }) {
|
||||
if (!widget) return null;
|
||||
|
||||
const p = widget.params;
|
||||
const doctors = (p.doctors || []) as DoctorOption[];
|
||||
const specialists = (p.specialists || []) as SpecialtyItem[];
|
||||
const city = String(p.city || '');
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, { specialist: SpecialtyItem | null; doctors: DoctorOption[] }>();
|
||||
for (const d of doctors) {
|
||||
const key = d.specialty;
|
||||
if (!map.has(key)) {
|
||||
const sp = specialists.find((s) => s.specialty === key) || null;
|
||||
map.set(key, { specialist: sp, doctors: [] });
|
||||
}
|
||||
map.get(key)!.doctors.push(d);
|
||||
}
|
||||
return Array.from(map.entries());
|
||||
}, [doctors, specialists]);
|
||||
|
||||
if (doctors.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<Stethoscope className="w-8 h-8 text-muted/30 mx-auto mb-2" />
|
||||
<p className="text-sm text-muted">Специалисты не найдены</p>
|
||||
<p className="text-xs text-muted/70 mt-1">Попробуйте указать другой город</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{city && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted">
|
||||
<MapPin className="w-3.5 h-3.5" />
|
||||
<span>Поиск в городе: <strong className="text-secondary">{city}</strong></span>
|
||||
<span className="text-muted/50">|</span>
|
||||
<span>Найдено: <strong className="text-secondary">{doctors.length}</strong></span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{grouped.map(([specialty, group]) => (
|
||||
<CollapsibleSection
|
||||
key={specialty}
|
||||
title={specialty}
|
||||
icon={Stethoscope}
|
||||
count={group.doctors.length}
|
||||
>
|
||||
{group.specialist && (
|
||||
<div className="flex items-center gap-2 mb-2 px-1">
|
||||
<span className="text-xs text-muted">Причина направления:</span>
|
||||
<span className="text-xs text-secondary">{group.specialist.reason}</span>
|
||||
{group.specialist.priority === 'high' && (
|
||||
<span className="text-3xs px-1.5 py-0.5 rounded bg-accent/15 text-accent font-medium">Приоритет</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{group.doctors.map((d) => (
|
||||
<div key={d.id} className="p-3 rounded-lg bg-surface/20 border border-border/15 space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-primary truncate">{d.clinic || d.name}</p>
|
||||
{d.sourceName && (
|
||||
<p className="text-xs text-muted mt-0.5">Источник: {d.sourceName}</p>
|
||||
)}
|
||||
</div>
|
||||
{d.sourceUrl && (
|
||||
<a
|
||||
href={d.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="shrink-0 flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium text-accent bg-accent/10 hover:bg-accent/20 border border-accent/20 rounded-lg transition-colors"
|
||||
>
|
||||
Записаться
|
||||
<ArrowUpRight className="w-3 h-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{d.snippet && (
|
||||
<p className="text-xs text-muted leading-relaxed line-clamp-3">{d.snippet}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleSection>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BookingTab({ widget }: { widget: MedicineWidget | undefined }) {
|
||||
if (!widget) return null;
|
||||
|
||||
const p = widget.params;
|
||||
const bookingLinks = (p.bookingLinks || p.slots || []) as BookingLink[];
|
||||
|
||||
if (bookingLinks.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<Calendar className="w-8 h-8 text-muted/30 mx-auto mb-2" />
|
||||
<p className="text-sm text-muted">Ссылки на запись не найдены</p>
|
||||
<p className="text-xs text-muted/70 mt-1">Попробуйте уточнить город или специальность</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, BookingLink[]>();
|
||||
for (const link of bookingLinks) {
|
||||
const key = link.specialty || 'Другое';
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(link);
|
||||
}
|
||||
return Array.from(map.entries());
|
||||
}, [bookingLinks]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="p-3 rounded-xl bg-accent/5 border border-accent/15">
|
||||
<div className="flex items-start gap-2">
|
||||
<Calendar className="w-4 h-4 text-accent mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-primary">Запись к специалистам</p>
|
||||
<p className="text-xs text-muted mt-0.5">
|
||||
Нажмите на ссылку, чтобы перейти на сайт клиники и выбрать удобное время.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{grouped.map(([specialty, links]) => (
|
||||
<CollapsibleSection key={specialty} title={specialty} icon={Stethoscope} count={links.length}>
|
||||
{links.map((link) => (
|
||||
<a
|
||||
key={link.id}
|
||||
href={link.bookUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-3 p-3 rounded-lg bg-surface/20 border border-border/15 hover:border-accent/30 hover:bg-accent/5 transition-all group"
|
||||
>
|
||||
<div className="w-9 h-9 rounded-lg bg-surface/40 flex items-center justify-center shrink-0 group-hover:bg-accent/15 transition-colors">
|
||||
{link.remote ? (
|
||||
<Monitor className="w-4 h-4 text-muted group-hover:text-accent transition-colors" />
|
||||
) : (
|
||||
<MapPin className="w-4 h-4 text-muted group-hover:text-accent transition-colors" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-primary truncate">{link.doctor}</p>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-xs text-muted truncate">{link.clinic}</span>
|
||||
{link.remote && (
|
||||
<span className="text-3xs px-1.5 py-0.5 rounded bg-blue-500/15 text-blue-400 font-medium shrink-0">Онлайн</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ExternalLink className="w-4 h-4 text-muted group-hover:text-accent transition-colors shrink-0" />
|
||||
</a>
|
||||
))}
|
||||
</CollapsibleSection>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReferenceTab({ widget }: { widget: MedicineWidget | undefined }) {
|
||||
if (!widget) return null;
|
||||
|
||||
const p = widget.params;
|
||||
const medications = (p.medicationInfo || []) as MedicationInfoItem[];
|
||||
const supplements = (p.supplementInfo || []) as SupplementInfoItem[];
|
||||
const procedures = (p.procedureInfo || []) as ProcedureInfoItem[];
|
||||
const note = String(p.note || '');
|
||||
|
||||
const hasContent = medications.length > 0 || supplements.length > 0 || procedures.length > 0;
|
||||
|
||||
if (!hasContent) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<Pill className="w-8 h-8 text-muted/30 mx-auto mb-2" />
|
||||
<p className="text-sm text-muted">Справочная информация отсутствует</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{note && (
|
||||
<div className="p-3 rounded-xl bg-amber-500/5 border border-amber-500/15">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-500 mt-0.5 shrink-0" />
|
||||
<p className="text-xs text-muted leading-relaxed">{note}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{medications.length > 0 && (
|
||||
<CollapsibleSection title="Лекарственные препараты" icon={Pill} count={medications.length}>
|
||||
<p className="text-xs text-muted mb-2">Только справочная информация. Назначения делает врач.</p>
|
||||
{medications.map((m, i) => (
|
||||
<div key={`med-${i}`} className="p-3 rounded-lg bg-surface/20 border border-border/15 space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Pill className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span className="text-sm font-medium text-primary">{m.name}</span>
|
||||
</div>
|
||||
<p className="text-xs text-secondary"><strong>Применяется:</strong> {m.forWhat}</p>
|
||||
{m.notes && <p className="text-xs text-muted italic">{m.notes}</p>}
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{supplements.length > 0 && (
|
||||
<CollapsibleSection title="БАДы и добавки" icon={Beaker} count={supplements.length}>
|
||||
{supplements.map((s, i) => {
|
||||
const ev = EVIDENCE_CONFIG[s.evidence] || EVIDENCE_CONFIG.low;
|
||||
return (
|
||||
<div key={`sup-${i}`} className="p-3 rounded-lg bg-surface/20 border border-border/15 space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Beaker className="w-3.5 h-3.5 text-green-400" />
|
||||
<span className="text-sm font-medium text-primary">{s.name}</span>
|
||||
</div>
|
||||
<span className={`text-3xs px-1.5 py-0.5 rounded font-medium ${ev.bg} ${ev.color}`}>
|
||||
{ev.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-secondary"><strong>Назначение:</strong> {s.purpose}</p>
|
||||
{s.notes && <p className="text-xs text-muted italic">{s.notes}</p>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{procedures.length > 0 && (
|
||||
<CollapsibleSection title="Обследования и процедуры" icon={Activity} count={procedures.length}>
|
||||
{procedures.map((pr, i) => (
|
||||
<div key={`proc-${i}`} className="p-3 rounded-lg bg-surface/20 border border-border/15 space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="w-3.5 h-3.5 text-purple-400" />
|
||||
<span className="text-sm font-medium text-primary">{pr.name}</span>
|
||||
</div>
|
||||
<p className="text-xs text-secondary"><strong>Что покажет:</strong> {pr.purpose}</p>
|
||||
{pr.whenUseful && (
|
||||
<p className="text-xs text-muted"><strong>Когда назначают:</strong> {pr.whenUseful}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MedicineWidgetTabsProps {
|
||||
widgets: MedicineWidget[];
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function MedicineWidgetTabs({ widgets, isLoading }: MedicineWidgetTabsProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabId>('assessment');
|
||||
|
||||
const byType = useMemo(() => {
|
||||
const out = new Map<string, MedicineWidget>();
|
||||
for (const w of widgets) {
|
||||
if (w.type.startsWith('medicine_')) out.set(w.type, w);
|
||||
}
|
||||
return out;
|
||||
}, [widgets]);
|
||||
|
||||
const assessment = byType.get('medicine_assessment');
|
||||
const doctors = byType.get('medicine_doctors');
|
||||
const appointments = byType.get('medicine_appointments');
|
||||
const reference = byType.get('medicine_reference');
|
||||
|
||||
const tabHasData = useCallback(
|
||||
(id: TabId): boolean => {
|
||||
switch (id) {
|
||||
case 'assessment': return Boolean(assessment);
|
||||
case 'doctors': return Boolean(doctors);
|
||||
case 'booking': return Boolean(appointments);
|
||||
case 'reference': return Boolean(reference);
|
||||
default: return false;
|
||||
}
|
||||
},
|
||||
[assessment, doctors, appointments, reference],
|
||||
);
|
||||
|
||||
const tabCounts = useMemo((): Record<TabId, number> => {
|
||||
const doctorList = (doctors?.params.doctors || []) as DoctorOption[];
|
||||
const bookingList = (appointments?.params.bookingLinks || appointments?.params.slots || []) as BookingLink[];
|
||||
const conditions = (assessment?.params.possibleConditions || []) as ConditionItem[];
|
||||
const meds = (reference?.params.medicationInfo || []) as MedicationInfoItem[];
|
||||
const sups = (reference?.params.supplementInfo || []) as SupplementInfoItem[];
|
||||
const procs = (reference?.params.procedureInfo || []) as ProcedureInfoItem[];
|
||||
|
||||
return {
|
||||
assessment: conditions.length,
|
||||
doctors: doctorList.length,
|
||||
booking: bookingList.length,
|
||||
reference: meds.length + sups.length + procs.length,
|
||||
};
|
||||
}, [assessment, doctors, appointments, reference]);
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 rounded-xl border border-border/50 bg-base overflow-hidden flex flex-col">
|
||||
<div className="h-11 shrink-0 flex items-center gap-0.5 px-2 pt-2 pb-0 overflow-x-auto scrollbar-hide bg-base border-b border-border/30">
|
||||
{TABS.map((tab) => {
|
||||
const isActive = activeTab === tab.id;
|
||||
const Icon = tab.icon;
|
||||
const count = tabCounts[tab.id];
|
||||
const hasData = tabHasData(tab.id);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`h-9 flex items-center gap-1.5 px-3 text-ui-sm font-medium rounded-t-lg transition-all whitespace-nowrap flex-shrink-0 border-b-2 ${
|
||||
isActive
|
||||
? 'bg-surface/60 text-primary border-accent'
|
||||
: 'text-muted hover:text-secondary hover:bg-surface/30 border-transparent'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{tab.label}
|
||||
{hasData && count > 0 && (
|
||||
<span
|
||||
className={`min-w-[18px] h-[14px] px-1.5 text-3xs rounded-full inline-flex items-center justify-center ${
|
||||
isActive ? 'bg-accent/20 text-accent' : 'bg-surface/50 text-muted'
|
||||
}`}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
{hasData && count === 0 && <span className="w-1.5 h-1.5 rounded-full bg-accent" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={activeTab}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="p-3"
|
||||
>
|
||||
{isLoading && widgets.length === 0 ? (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-muted">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span className="text-xs">Анализ симптомов...</span>
|
||||
</div>
|
||||
) : !tabHasData(activeTab) ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-8 text-muted">
|
||||
<CircleAlert className="w-5 h-5 opacity-40" />
|
||||
<span className="text-xs">{TABS.find((t) => t.id === activeTab)?.emptyLabel}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{activeTab === 'assessment' && <AssessmentTab widget={assessment} />}
|
||||
{activeTab === 'doctors' && <DoctorsTab widget={doctors} />}
|
||||
{activeTab === 'booking' && <BookingTab widget={appointments} />}
|
||||
{activeTab === 'reference' && <ReferenceTab widget={reference} />}
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
643
backend/webui/src/components/SandboxPanel.tsx
Normal file
643
backend/webui/src/components/SandboxPanel.tsx
Normal file
@@ -0,0 +1,643 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useRef, useMemo, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import dynamic from 'next/dynamic';
|
||||
import {
|
||||
FileCode,
|
||||
Terminal,
|
||||
Play,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
File,
|
||||
Folder,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Plus,
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from '@/lib/contexts/ThemeContext';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
|
||||
const MonacoEditor = dynamic(() => import('@monaco-editor/react'), { ssr: false });
|
||||
|
||||
function getAuthHeaders(): HeadersInit {
|
||||
const headers: HeadersInit = { 'Content-Type': 'application/json' };
|
||||
if (typeof window !== 'undefined') {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
interface FileNode {
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'file' | 'dir';
|
||||
children?: FileNode[];
|
||||
}
|
||||
|
||||
interface SandboxPanelProps {
|
||||
sessionId: string;
|
||||
onVerifyResult?: (passed: boolean, result: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export default function SandboxPanel({ sessionId, onVerifyResult }: SandboxPanelProps) {
|
||||
const { theme } = useTheme();
|
||||
const [activeTab, setActiveTab] = useState<'editor' | 'terminal'>('editor');
|
||||
const [files, setFiles] = useState<FileNode[]>([]);
|
||||
const [isLoadingFiles, setIsLoadingFiles] = useState(false);
|
||||
const [loadingDirs, setLoadingDirs] = useState<Set<string>>(new Set());
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set(['/home/user']));
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
const [fileContent, setFileContent] = useState('');
|
||||
const [originalFileContent, setOriginalFileContent] = useState<string>('');
|
||||
const [isLoadingFile, setIsLoadingFile] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [terminalOutput, setTerminalOutput] = useState('');
|
||||
const [terminalCmd, setTerminalCmd] = useState('');
|
||||
const [isRunningCmd, setIsRunningCmd] = useState(false);
|
||||
const [cmdHistory, setCmdHistory] = useState<string[]>([]);
|
||||
const [historyIndex, setHistoryIndex] = useState<number>(-1);
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
const [verifyResult, setVerifyResult] = useState<{ passed: boolean; stdout: string } | null>(null);
|
||||
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const editorRef = useRef<unknown>(null);
|
||||
|
||||
const isDirty = useMemo(() => selectedFile != null && fileContent !== originalFileContent, [
|
||||
selectedFile,
|
||||
fileContent,
|
||||
originalFileContent,
|
||||
]);
|
||||
|
||||
const parseFileNodes = useCallback((data: unknown): FileNode[] => {
|
||||
const rawEntries: unknown[] = Array.isArray(data)
|
||||
? data
|
||||
: (data as { entries?: unknown[] } | null)?.entries && Array.isArray((data as { entries?: unknown[] }).entries)
|
||||
? ((data as { entries?: unknown[] }).entries as unknown[])
|
||||
: [];
|
||||
|
||||
return rawEntries
|
||||
.map((e) => {
|
||||
const entry = e as { name?: unknown; path?: unknown; type?: unknown };
|
||||
const name = typeof entry.name === 'string' ? entry.name : '';
|
||||
const path = typeof entry.path === 'string' ? entry.path : '';
|
||||
const typeRaw = typeof entry.type === 'string' ? entry.type : 'file';
|
||||
const type: 'file' | 'dir' = typeRaw === 'dir' || typeRaw === 'directory' ? 'dir' : 'file';
|
||||
if (!name || !path) return null;
|
||||
return { name, path, type } satisfies FileNode;
|
||||
})
|
||||
.filter((v): v is FileNode => v != null)
|
||||
.sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateNodeChildren = useCallback((nodes: FileNode[], dirPath: string, children: FileNode[]): FileNode[] => {
|
||||
return nodes.map((n) => {
|
||||
if (n.type === 'dir' && n.path === dirPath) {
|
||||
return { ...n, children };
|
||||
}
|
||||
if (n.type === 'dir' && n.children) {
|
||||
return { ...n, children: updateNodeChildren(n.children, dirPath, children) };
|
||||
}
|
||||
return n;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const fetchDir = useCallback(
|
||||
async (path: string): Promise<FileNode[]> => {
|
||||
const resp = await fetch(
|
||||
`${API_BASE}/api/v1/sandbox/sessions/${sessionId}/files?path=${encodeURIComponent(path)}`,
|
||||
{ headers: getAuthHeaders() }
|
||||
);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`list files failed: ${resp.status}`);
|
||||
}
|
||||
const data: unknown = await resp.json();
|
||||
return parseFileNodes(data);
|
||||
},
|
||||
[sessionId, parseFileNodes]
|
||||
);
|
||||
|
||||
const loadFiles = useCallback(async (path = '/home/user') => {
|
||||
setIsLoadingFiles(true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const nodes = await fetchDir(path);
|
||||
if (path === '/home/user') setFiles(nodes);
|
||||
return nodes;
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'failed to load files';
|
||||
setErrorMessage(msg);
|
||||
return [];
|
||||
} finally {
|
||||
setIsLoadingFiles(false);
|
||||
}
|
||||
}, [fetchDir]);
|
||||
|
||||
useEffect(() => {
|
||||
loadFiles();
|
||||
}, [loadFiles]);
|
||||
|
||||
const openFile = useCallback(async (path: string) => {
|
||||
setSelectedFile(path);
|
||||
setActiveTab('editor');
|
||||
setIsLoadingFile(true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/api/v1/sandbox/sessions/${sessionId}/file?path=${encodeURIComponent(path)}`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`read file failed: ${resp.status}`);
|
||||
const data: unknown = await resp.json();
|
||||
const content = (data as { content?: unknown } | null)?.content;
|
||||
const text = typeof content === 'string' ? content : '';
|
||||
setFileContent(text);
|
||||
setOriginalFileContent(text);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'Error loading file';
|
||||
setErrorMessage(msg);
|
||||
setFileContent('');
|
||||
setOriginalFileContent('');
|
||||
} finally {
|
||||
setIsLoadingFile(false);
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
const saveFile = useCallback(async () => {
|
||||
if (!selectedFile) return;
|
||||
setIsSaving(true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/api/v1/sandbox/sessions/${sessionId}/file`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ path: selectedFile, content: fileContent }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`write file failed: ${resp.status}`);
|
||||
setOriginalFileContent(fileContent);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [sessionId, selectedFile, fileContent]);
|
||||
|
||||
const toggleDir = useCallback(
|
||||
async (node: FileNode) => {
|
||||
if (node.type !== 'dir') return;
|
||||
const willExpand = !expandedDirs.has(node.path);
|
||||
setExpandedDirs((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(node.path)) next.delete(node.path);
|
||||
else next.add(node.path);
|
||||
return next;
|
||||
});
|
||||
|
||||
if (!willExpand) return;
|
||||
if (node.children && node.children.length > 0) return;
|
||||
|
||||
setLoadingDirs((prev) => new Set(prev).add(node.path));
|
||||
try {
|
||||
const children = await fetchDir(node.path);
|
||||
setFiles((prev) => updateNodeChildren(prev, node.path, children));
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'failed to load directory';
|
||||
setErrorMessage(msg);
|
||||
} finally {
|
||||
setLoadingDirs((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(node.path);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[expandedDirs, fetchDir, updateNodeChildren]
|
||||
);
|
||||
|
||||
const runCommand = useCallback(async () => {
|
||||
if (!terminalCmd.trim()) return;
|
||||
const cmd = terminalCmd.trim();
|
||||
setTerminalCmd('');
|
||||
setIsRunningCmd(true);
|
||||
setHistoryIndex(-1);
|
||||
setCmdHistory((prev) => (prev[prev.length - 1] === cmd ? prev : [...prev, cmd]));
|
||||
setTerminalOutput((prev) => prev + `$ ${cmd}\n`);
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/api/v1/sandbox/sessions/${sessionId}/commands/run`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ command: cmd }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
setTerminalOutput((prev) => prev + `Error: ${resp.status}\n`);
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
const stdout = data.stdout || '';
|
||||
const stderr = data.stderr || '';
|
||||
const exitCode = data.exit_code ?? 0;
|
||||
setTerminalOutput((prev) =>
|
||||
prev + stdout + (stderr ? `\nstderr: ${stderr}` : '') + `\n[exit: ${exitCode}]\n\n`
|
||||
);
|
||||
} catch (err) {
|
||||
setTerminalOutput((prev) => prev + `Error: ${err}\n`);
|
||||
} finally {
|
||||
setIsRunningCmd(false);
|
||||
}
|
||||
}, [sessionId, terminalCmd]);
|
||||
|
||||
const runVerify = useCallback(async () => {
|
||||
setIsVerifying(true);
|
||||
setVerifyResult(null);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
if (isDirty && selectedFile) {
|
||||
await saveFile();
|
||||
}
|
||||
const resp = await fetch(`${API_BASE}/api/v1/sandbox/sessions/${sessionId}/verify`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
setVerifyResult({ passed: false, stdout: `Error: ${resp.status}` });
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
const result = {
|
||||
passed: data.passed === true,
|
||||
stdout: (data.result?.stdout || '') as string,
|
||||
};
|
||||
setVerifyResult(result);
|
||||
onVerifyResult?.(result.passed, data.result || {});
|
||||
} catch {
|
||||
setVerifyResult({ passed: false, stdout: 'Verification failed' });
|
||||
} finally {
|
||||
setIsVerifying(false);
|
||||
}
|
||||
}, [sessionId, onVerifyResult, isDirty, selectedFile, saveFile]);
|
||||
|
||||
useEffect(() => {
|
||||
terminalRef.current?.scrollTo(0, terminalRef.current.scrollHeight);
|
||||
}, [terminalOutput]);
|
||||
|
||||
const fileName = useMemo(() => {
|
||||
if (!selectedFile) return '';
|
||||
return selectedFile.split('/').pop() || '';
|
||||
}, [selectedFile]);
|
||||
|
||||
const editorLanguage = useMemo(() => detectLanguage(selectedFile), [selectedFile]);
|
||||
|
||||
const createNewFile = useCallback(async () => {
|
||||
const p = window.prompt('Путь нового файла (пример: /home/user/main.go)');
|
||||
if (!p) return;
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/api/v1/sandbox/sessions/${sessionId}/file`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ path: p, content: '' }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`create file failed: ${resp.status}`);
|
||||
await loadFiles('/home/user');
|
||||
await openFile(p);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'create file failed';
|
||||
setErrorMessage(msg);
|
||||
}
|
||||
}, [sessionId, loadFiles, openFile]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background border-l border-border/30">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border/30 bg-elevated/40">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded icon-gradient flex items-center justify-center">
|
||||
<FileCode className="w-3 h-3" />
|
||||
</div>
|
||||
<span className="text-xs font-medium text-primary">Песочница</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => loadFiles()}
|
||||
className="p-1.5 rounded-md text-muted hover:text-primary hover:bg-surface/60 transition-all"
|
||||
title="Обновить файлы"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={createNewFile}
|
||||
className="p-1.5 rounded-md text-muted hover:text-primary hover:bg-surface/60 transition-all"
|
||||
title="Новый файл"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={runVerify}
|
||||
disabled={isVerifying}
|
||||
className="flex items-center gap-1 px-2 py-1 text-[10px] btn-gradient rounded-md"
|
||||
>
|
||||
{isVerifying ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin btn-gradient-text" />
|
||||
) : (
|
||||
<Play className="w-3 h-3 btn-gradient-text" />
|
||||
)}
|
||||
<span className="btn-gradient-text">Проверить</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error banner */}
|
||||
<AnimatePresence>
|
||||
{errorMessage && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="px-3 py-2 text-xs bg-error/10 text-error border-b border-error/20 overflow-hidden"
|
||||
>
|
||||
{errorMessage}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Verify result banner */}
|
||||
<AnimatePresence>
|
||||
{verifyResult && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className={`px-3 py-2 text-xs flex items-center gap-2 overflow-hidden ${
|
||||
verifyResult.passed
|
||||
? 'bg-success/10 text-success border-b border-success/20'
|
||||
: 'bg-error/10 text-error border-b border-error/20'
|
||||
}`}
|
||||
>
|
||||
{verifyResult.passed ? (
|
||||
<CheckCircle2 className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
) : (
|
||||
<XCircle className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
)}
|
||||
{verifyResult.passed ? 'Тест пройден!' : 'Тест не пройден'}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Main area */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* File tree */}
|
||||
<div className="w-[180px] border-r border-border/30 overflow-y-auto bg-surface/20">
|
||||
<div className="px-2 py-1.5 text-[10px] text-muted uppercase tracking-wider">Файлы</div>
|
||||
{isLoadingFiles ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="w-3 h-3 animate-spin text-muted" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs">
|
||||
{files.map((node) => (
|
||||
<FileTreeNode
|
||||
key={node.path || node.name}
|
||||
node={node}
|
||||
expanded={expandedDirs}
|
||||
loading={loadingDirs}
|
||||
selected={selectedFile}
|
||||
onToggle={toggleDir}
|
||||
onSelect={openFile}
|
||||
depth={0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Editor / Terminal */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-0 border-b border-border/30 bg-elevated/20">
|
||||
<button
|
||||
onClick={() => setActiveTab('editor')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-[11px] border-b-2 transition-all ${
|
||||
activeTab === 'editor'
|
||||
? 'border-accent text-primary'
|
||||
: 'border-transparent text-muted hover:text-secondary'
|
||||
}`}
|
||||
>
|
||||
<FileCode className="w-3 h-3" />
|
||||
{fileName || 'Редактор'}
|
||||
{isDirty && <span className="text-[10px] text-warning ml-1">•</span>}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('terminal')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-[11px] border-b-2 transition-all ${
|
||||
activeTab === 'terminal'
|
||||
? 'border-accent text-primary'
|
||||
: 'border-transparent text-muted hover:text-secondary'
|
||||
}`}
|
||||
>
|
||||
<Terminal className="w-3 h-3" />
|
||||
Терминал
|
||||
</button>
|
||||
{activeTab === 'editor' && selectedFile && (
|
||||
<button
|
||||
onClick={saveFile}
|
||||
disabled={isSaving}
|
||||
className="ml-auto mr-2 p-1 text-muted hover:text-primary transition-all"
|
||||
title="Сохранить"
|
||||
>
|
||||
{isSaving ? <Loader2 className="w-3 h-3 animate-spin" /> : <Save className="w-3 h-3" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{activeTab === 'editor' ? (
|
||||
<div className="flex-1 overflow-auto p-0">
|
||||
{selectedFile ? (
|
||||
isLoadingFile ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full">
|
||||
<MonacoEditor
|
||||
value={fileContent}
|
||||
onChange={(v) => setFileContent(v ?? '')}
|
||||
language={editorLanguage}
|
||||
theme={theme === 'dim' ? 'vs-dark' : 'vs'}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 12,
|
||||
wordWrap: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
}}
|
||||
onMount={(editor, monaco) => {
|
||||
editorRef.current = editor as unknown;
|
||||
const editorLike = editor as unknown as { addCommand: (keybinding: number, handler: () => void) => void };
|
||||
const monacoLike = monaco as unknown as { KeyMod: { CtrlCmd: number }; KeyCode: { KeyS: number } };
|
||||
editorLike.addCommand(monacoLike.KeyMod.CtrlCmd | monacoLike.KeyCode.KeyS, () => {
|
||||
void saveFile();
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<FileCode className="w-8 h-8 text-muted mb-2" />
|
||||
<p className="text-xs text-muted">Выберите файл</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div
|
||||
ref={terminalRef}
|
||||
className={`flex-1 overflow-auto p-3 font-mono text-xs whitespace-pre-wrap ${
|
||||
theme === 'dim'
|
||||
? 'text-emerald-300 bg-black/30'
|
||||
: 'text-emerald-700 bg-elevated/50 border border-border/30'
|
||||
}`}
|
||||
>
|
||||
{terminalOutput || '$ Введите команду...\n'}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-t border-border/30 bg-surface/20">
|
||||
<span className={`text-xs font-mono ${theme === 'dim' ? 'text-emerald-300' : 'text-emerald-700'}`}>$</span>
|
||||
<input
|
||||
value={terminalCmd}
|
||||
onChange={(e) => setTerminalCmd(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
runCommand();
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (cmdHistory.length === 0) return;
|
||||
const nextIndex = historyIndex < 0 ? cmdHistory.length - 1 : Math.max(0, historyIndex - 1);
|
||||
setHistoryIndex(nextIndex);
|
||||
setTerminalCmd(cmdHistory[nextIndex] || '');
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (cmdHistory.length === 0) return;
|
||||
if (historyIndex < 0) return;
|
||||
const nextIndex = Math.min(cmdHistory.length - 1, historyIndex + 1);
|
||||
setHistoryIndex(nextIndex);
|
||||
setTerminalCmd(cmdHistory[nextIndex] || '');
|
||||
}
|
||||
}}
|
||||
placeholder="Введите команду..."
|
||||
className="flex-1 bg-transparent text-xs font-mono text-primary focus:outline-none placeholder:text-muted"
|
||||
disabled={isRunningCmd}
|
||||
/>
|
||||
{isRunningCmd && <Loader2 className="w-3 h-3 animate-spin text-muted" />}
|
||||
<button
|
||||
onClick={() => setTerminalOutput('')}
|
||||
className="p-1 rounded-md text-muted hover:text-primary hover:bg-surface/60 transition-all"
|
||||
title="Очистить"
|
||||
disabled={isRunningCmd}
|
||||
>
|
||||
<XCircle className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileTreeNode({
|
||||
node,
|
||||
expanded,
|
||||
loading,
|
||||
selected,
|
||||
onToggle,
|
||||
onSelect,
|
||||
depth,
|
||||
}: {
|
||||
node: FileNode;
|
||||
expanded: Set<string>;
|
||||
loading: Set<string>;
|
||||
selected: string | null;
|
||||
onToggle: (node: FileNode) => void | Promise<void>;
|
||||
onSelect: (path: string) => void;
|
||||
depth: number;
|
||||
}) {
|
||||
const isDir = node.type === 'dir';
|
||||
const isExpanded = expanded.has(node.path);
|
||||
const isSelected = selected === node.path;
|
||||
const isLoading = loading.has(node.path);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isDir) {
|
||||
void onToggle(node);
|
||||
} else {
|
||||
onSelect(node.path);
|
||||
}
|
||||
}}
|
||||
className={`w-full flex items-center gap-1 px-2 py-0.5 text-left hover:bg-surface/60 transition-colors ${
|
||||
isSelected ? 'bg-accent/10 text-accent' : 'text-secondary'
|
||||
}`}
|
||||
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
||||
>
|
||||
{isDir ? (
|
||||
<>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-2.5 h-2.5 flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-2.5 h-2.5 flex-shrink-0" />
|
||||
)}
|
||||
<Folder className="w-3 h-3 text-amber-400 flex-shrink-0" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="w-2.5" />
|
||||
<File className="w-3 h-3 text-muted flex-shrink-0" />
|
||||
</>
|
||||
)}
|
||||
<span className="truncate text-[11px]">{node.name}</span>
|
||||
{isDir && isExpanded && isLoading && <Loader2 className="w-3 h-3 ml-auto animate-spin text-muted" />}
|
||||
</button>
|
||||
{isDir && isExpanded && node.children?.map((child) => (
|
||||
<FileTreeNode
|
||||
key={child.path || child.name}
|
||||
node={child}
|
||||
expanded={expanded}
|
||||
loading={loading}
|
||||
selected={selected}
|
||||
onToggle={onToggle}
|
||||
onSelect={onSelect}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function detectLanguage(path: string | null): string {
|
||||
if (!path) return 'plaintext';
|
||||
const lower = path.toLowerCase();
|
||||
if (lower.endsWith('.go')) return 'go';
|
||||
if (lower.endsWith('.ts')) return 'typescript';
|
||||
if (lower.endsWith('.tsx')) return 'typescript';
|
||||
if (lower.endsWith('.js')) return 'javascript';
|
||||
if (lower.endsWith('.jsx')) return 'javascript';
|
||||
if (lower.endsWith('.py')) return 'python';
|
||||
if (lower.endsWith('.sql')) return 'sql';
|
||||
if (lower.endsWith('.json')) return 'json';
|
||||
if (lower.endsWith('.yaml') || lower.endsWith('.yml')) return 'yaml';
|
||||
if (lower.endsWith('.md')) return 'markdown';
|
||||
if (lower.endsWith('.sh')) return 'shell';
|
||||
return 'plaintext';
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { filterMenuItems } from '@/lib/config/menu';
|
||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||
import { ThemeToggle } from '@/components/ThemeToggle';
|
||||
|
||||
interface SidebarProps {
|
||||
onClose?: () => void;
|
||||
@@ -65,12 +66,12 @@ export function Sidebar({ onClose }: SidebarProps) {
|
||||
return (
|
||||
<motion.aside
|
||||
initial={false}
|
||||
animate={{ width: isMobile ? 240 : collapsed ? 56 : 200 }}
|
||||
animate={{ width: isMobile ? 260 : collapsed ? 60 : 220 }}
|
||||
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
|
||||
className="h-full flex flex-col bg-base border-r border-border/50"
|
||||
className="relative z-30 h-full flex flex-col bg-base border-r border-border/50"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="h-12 px-3 flex items-center justify-between">
|
||||
<div className="h-14 px-3 flex items-center justify-between">
|
||||
<AnimatePresence mode="wait">
|
||||
{(isMobile || !collapsed) && (
|
||||
<motion.div
|
||||
@@ -79,7 +80,7 @@ export function Sidebar({ onClose }: SidebarProps) {
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<span className="font-black italic text-primary tracking-tight text-xl">GooSeek</span>
|
||||
<span className="font-black italic text-primary tracking-tight text-2xl">GooSeek</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
@@ -106,7 +107,7 @@ export function Sidebar({ onClose }: SidebarProps) {
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-2 py-2 space-y-0.5 overflow-y-auto">
|
||||
<nav className="flex-1 px-2 py-3 space-y-1 overflow-y-auto">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.href}
|
||||
@@ -120,9 +121,9 @@ export function Sidebar({ onClose }: SidebarProps) {
|
||||
))}
|
||||
|
||||
{/* Tools Section */}
|
||||
<div className="pt-4 pb-1">
|
||||
<div className="pt-5 pb-1.5">
|
||||
{(isMobile || !collapsed) && (
|
||||
<span className="px-2 text-[10px] font-semibold text-muted uppercase tracking-wider">
|
||||
<span className="px-3 text-xs font-semibold text-muted uppercase tracking-wider">
|
||||
Инструменты
|
||||
</span>
|
||||
)}
|
||||
@@ -144,6 +145,17 @@ export function Sidebar({ onClose }: SidebarProps) {
|
||||
|
||||
{/* Footer - Profile Block */}
|
||||
<div className="p-2 border-t border-border/30">
|
||||
{/* Theme toggle */}
|
||||
<div className={`${isAdmin ? '' : ''} mb-2`}>
|
||||
{collapsed && !isMobile ? (
|
||||
<div className="flex justify-center">
|
||||
<ThemeToggle variant="icon" />
|
||||
</div>
|
||||
) : (
|
||||
<ThemeToggle variant="full" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<NavLink
|
||||
href="/admin"
|
||||
@@ -157,21 +169,21 @@ export function Sidebar({ onClose }: SidebarProps) {
|
||||
|
||||
{/* Guest - Login button */}
|
||||
{!isAuthenticated && (
|
||||
<div className={`${isAdmin ? 'mt-1' : ''}`}>
|
||||
<div className={`${isAdmin ? 'mt-1.5' : ''}`}>
|
||||
{collapsed && !isMobile ? (
|
||||
<button
|
||||
onClick={() => showAuthModal('login')}
|
||||
className="btn-gradient w-full h-9 flex items-center justify-center"
|
||||
className="btn-gradient w-full h-10 flex items-center justify-center rounded-lg"
|
||||
>
|
||||
<LogIn className="w-4 h-4 btn-gradient-text" />
|
||||
<LogIn className="w-[18px] h-[18px] btn-gradient-text" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => { showAuthModal('login'); handleNavClick(); }}
|
||||
className="btn-gradient w-full h-9 flex items-center justify-center gap-2"
|
||||
className="btn-gradient w-full h-10 flex items-center justify-center gap-2.5 rounded-lg"
|
||||
>
|
||||
<LogIn className="w-3.5 h-3.5 btn-gradient-text" />
|
||||
<span className="btn-gradient-text text-xs font-medium">Войти</span>
|
||||
<LogIn className="w-[18px] h-[18px] btn-gradient-text" />
|
||||
<span className="btn-gradient-text text-sm font-medium">Войти</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -197,7 +209,7 @@ export function Sidebar({ onClose }: SidebarProps) {
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-surface/50">
|
||||
<Wallet className="w-2.5 h-2.5 text-accent" />
|
||||
<span className="text-[10px] font-medium text-primary">{user.balance ?? 0}</span>
|
||||
<span className="text-2xs font-medium text-primary">{user.balance ?? 0}</span>
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
@@ -216,9 +228,9 @@ export function Sidebar({ onClose }: SidebarProps) {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs font-medium text-primary truncate">{user.name}</span>
|
||||
<span className={`text-[9px] font-medium px-1 py-0.5 rounded ${
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium text-primary truncate">{user.name}</span>
|
||||
<span className={`text-2xs font-medium px-1.5 py-0.5 rounded ${
|
||||
user.tier === 'business'
|
||||
? 'bg-amber-500/20 text-amber-600'
|
||||
: user.tier === 'pro'
|
||||
@@ -229,8 +241,8 @@ export function Sidebar({ onClose }: SidebarProps) {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<Wallet className="w-2.5 h-2.5 text-accent" />
|
||||
<span className="text-[10px] font-medium text-secondary">{(user.balance ?? 0).toLocaleString('ru-RU')} ₽</span>
|
||||
<Wallet className="w-3 h-3 text-accent" />
|
||||
<span className="text-xs font-medium text-secondary">{(user.balance ?? 0).toLocaleString('ru-RU')} ₽</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRightIcon className="w-3.5 h-3.5 text-muted group-hover:text-secondary transition-colors" />
|
||||
@@ -258,17 +270,17 @@ function NavLink({ href, icon: Icon, label, collapsed, active, onClick }: NavLin
|
||||
href={href}
|
||||
onClick={onClick}
|
||||
className={`
|
||||
flex items-center gap-2 h-9 rounded-lg transition-all duration-150
|
||||
${collapsed ? 'justify-center px-0' : 'px-2'}
|
||||
flex items-center gap-3 h-10 rounded-lg transition-all duration-150
|
||||
${collapsed ? 'justify-center px-0' : 'px-3'}
|
||||
${active
|
||||
? 'active-gradient text-primary border-l-gradient ml-0'
|
||||
: 'text-secondary hover:text-primary hover:bg-surface/50'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Icon className={`w-4 h-4 flex-shrink-0 ${active ? 'text-gradient' : ''}`} />
|
||||
<Icon className={`w-[18px] h-[18px] flex-shrink-0 ${active ? 'text-gradient' : ''}`} />
|
||||
{!collapsed && (
|
||||
<span className={`text-xs font-medium truncate ${active ? 'text-gradient' : ''}`}>{label}</span>
|
||||
<span className={`text-sm font-medium truncate ${active ? 'text-gradient' : ''}`}>{label}</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
|
||||
37
backend/webui/src/components/ThemeToggle.tsx
Normal file
37
backend/webui/src/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { useTheme } from '@/lib/contexts/ThemeContext';
|
||||
|
||||
export function ThemeToggle({
|
||||
variant = 'icon',
|
||||
}: {
|
||||
variant?: 'icon' | 'full';
|
||||
}) {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
const isDim = theme === 'dim';
|
||||
const label = isDim ? 'Тема: GitHub Dimmed' : 'Тема: GitHub Light';
|
||||
|
||||
if (variant === 'full') {
|
||||
return (
|
||||
<button onClick={toggleTheme} className="btn-secondary w-full h-10 flex items-center justify-center gap-2" aria-label={label}>
|
||||
{isDim ? <Moon className="w-4 h-4" /> : <Sun className="w-4 h-4" />}
|
||||
<span className="text-sm font-medium">{isDim ? 'Dimmed' : 'Light'}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="btn-icon"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
type="button"
|
||||
>
|
||||
{isDim ? <Moon className="w-[18px] h-[18px]" /> : <Sun className="w-[18px] h-[18px]" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
MapPin,
|
||||
@@ -24,8 +24,11 @@ import {
|
||||
CalendarDays,
|
||||
Flag,
|
||||
User,
|
||||
Clock,
|
||||
Layers,
|
||||
} from 'lucide-react';
|
||||
import type { RoutePoint, RouteDirection, GeoLocation } from '@/lib/types';
|
||||
import { getRoute } from '@/lib/api';
|
||||
|
||||
interface MapglAPI {
|
||||
Map: new (container: HTMLElement | string, options: Record<string, unknown>) => MapglMapInstance;
|
||||
@@ -116,6 +119,30 @@ const pointTypeColors: Record<string, string> = {
|
||||
origin: '#10B981',
|
||||
};
|
||||
|
||||
const pointTypeLabels: Record<string, string> = {
|
||||
airport: 'Аэропорт',
|
||||
hotel: 'Отель',
|
||||
restaurant: 'Ресторан',
|
||||
attraction: 'Достопримечательность',
|
||||
transport: 'Транспорт',
|
||||
custom: 'Точка',
|
||||
museum: 'Музей',
|
||||
park: 'Парк',
|
||||
theater: 'Театр',
|
||||
shopping: 'Шопинг',
|
||||
entertainment: 'Развлечения',
|
||||
religious: 'Храм',
|
||||
viewpoint: 'Смотровая',
|
||||
event: 'Событие',
|
||||
destination: 'Пункт назначения',
|
||||
poi: 'Место',
|
||||
food: 'Еда',
|
||||
transfer: 'Пересадка',
|
||||
origin: 'Старт',
|
||||
};
|
||||
|
||||
const ITINERARY_TYPES = new Set(['destination', 'origin', 'transfer', 'airport']);
|
||||
|
||||
let mapglPromise: Promise<MapglAPI> | null = null;
|
||||
|
||||
function loadMapGL(): Promise<MapglAPI> {
|
||||
@@ -128,20 +155,46 @@ function loadMapGL(): Promise<MapglAPI> {
|
||||
return mapglPromise;
|
||||
}
|
||||
|
||||
function createMarkerSVG(index: number, color: string): string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28">` +
|
||||
`<circle cx="14" cy="14" r="13" fill="${color}" stroke="white" stroke-width="2"/>` +
|
||||
`<text x="14" y="18" text-anchor="middle" fill="white" font-size="12" font-weight="bold">${index}</text>` +
|
||||
function createNumberedPinMarkerSVG(index: number, color: string): string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="26" height="34" viewBox="0 0 26 34">` +
|
||||
`<defs>` +
|
||||
`<filter id="is${index}" x="-15%" y="-8%" width="130%" height="130%">` +
|
||||
`<feDropShadow dx="0" dy="1" stdDeviation="1" flood-color="#000" flood-opacity="0.2"/>` +
|
||||
`</filter>` +
|
||||
`</defs>` +
|
||||
`<path d="M13 32 C13 32 24 20 24 12 C24 6.5 19.1 2 13 2 C6.9 2 2 6.5 2 12 C2 20 13 32 13 32Z" fill="${color}" filter="url(#is${index})" stroke="white" stroke-width="1.5"/>` +
|
||||
`<circle cx="13" cy="12" r="7" fill="white" fill-opacity="0.95"/>` +
|
||||
`<text x="13" y="15.5" text-anchor="middle" fill="${color}" font-size="10" font-weight="700" font-family="Inter,system-ui,sans-serif">${index}</text>` +
|
||||
`</svg>`;
|
||||
}
|
||||
|
||||
function createUserLocationSVG(): string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">` +
|
||||
`<circle cx="16" cy="16" r="14" fill="#3B82F6" fill-opacity="0.2" stroke="#3B82F6" stroke-width="2"/>` +
|
||||
`<circle cx="16" cy="16" r="6" fill="#3B82F6" stroke="white" stroke-width="2"/>` +
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28">` +
|
||||
`<circle cx="14" cy="14" r="12" fill="#3B82F6" fill-opacity="0.15" stroke="#3B82F6" stroke-width="1.5"/>` +
|
||||
`<circle cx="14" cy="14" r="5" fill="#3B82F6" stroke="white" stroke-width="1.5"/>` +
|
||||
`</svg>`;
|
||||
}
|
||||
|
||||
function getCategoryAbbr(type: string): string {
|
||||
const map: Record<string, string> = {
|
||||
hotel: 'H',
|
||||
restaurant: 'R',
|
||||
food: 'R',
|
||||
attraction: 'A',
|
||||
museum: 'M',
|
||||
park: 'P',
|
||||
theater: 'T',
|
||||
shopping: 'S',
|
||||
entertainment: 'E',
|
||||
religious: 'C',
|
||||
viewpoint: 'V',
|
||||
event: 'Ev',
|
||||
poi: 'P',
|
||||
custom: '?',
|
||||
};
|
||||
return map[type] || type.slice(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
export function TravelMap({
|
||||
route,
|
||||
routeDirection,
|
||||
@@ -158,12 +211,16 @@ export function TravelMap({
|
||||
const mapglRef = useRef<MapglAPI | null>(null);
|
||||
const markersRef = useRef<MapglMarkerInstance[]>([]);
|
||||
const userMarkerRef = useRef<MapglMarkerInstance | null>(null);
|
||||
const polylineRef = useRef<MapglPolylineInstance | null>(null);
|
||||
const polylinesRef = useRef<MapglPolylineInstance[]>([]);
|
||||
const onMapClickRef = useRef(onMapClick);
|
||||
const onPointClickRef = useRef(onPointClick);
|
||||
const [selectedPoint, setSelectedPoint] = useState<RoutePoint | null>(null);
|
||||
const [isMapReady, setIsMapReady] = useState(false);
|
||||
const [detectedLocation, setDetectedLocation] = useState<GeoLocation | null>(null);
|
||||
const [showLegend, setShowLegend] = useState(false);
|
||||
const [routeType, setRouteType] = useState<'road' | 'straight' | 'none'>('none');
|
||||
const [fallbackDirection, setFallbackDirection] = useState<RouteDirection | null>(null);
|
||||
const fallbackRequestRef = useRef<string>('');
|
||||
const initDoneRef = useRef(false);
|
||||
|
||||
onMapClickRef.current = onMapClick;
|
||||
@@ -171,6 +228,24 @@ export function TravelMap({
|
||||
|
||||
const effectiveUserLocation = userLocation ?? detectedLocation;
|
||||
|
||||
const activeTypes = useMemo(() => {
|
||||
const types = new Set<string>();
|
||||
route.forEach((p) => types.add(p.type));
|
||||
return types;
|
||||
}, [route]);
|
||||
|
||||
const pointNumberById = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
let idx = 0;
|
||||
route.forEach((p) => {
|
||||
if (!p.id) return;
|
||||
if (!p.lat || !p.lng || p.lat === 0 || p.lng === 0) return;
|
||||
idx += 1;
|
||||
map.set(p.id, idx);
|
||||
});
|
||||
return map;
|
||||
}, [route]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapRef.current || initDoneRef.current) return;
|
||||
initDoneRef.current = true;
|
||||
@@ -190,6 +265,9 @@ export function TravelMap({
|
||||
zoom,
|
||||
key: TWOGIS_API_KEY,
|
||||
lang: 'ru',
|
||||
// Hide built-in MapGL controls (we render our own UI controls).
|
||||
zoomControl: false,
|
||||
geolocationControl: false,
|
||||
});
|
||||
|
||||
map.on('click', (e: MapglClickEvent) => {
|
||||
@@ -214,10 +292,8 @@ export function TravelMap({
|
||||
userMarkerRef.current.destroy();
|
||||
userMarkerRef.current = null;
|
||||
}
|
||||
if (polylineRef.current) {
|
||||
polylineRef.current.destroy();
|
||||
polylineRef.current = null;
|
||||
}
|
||||
polylinesRef.current.forEach((p) => p.destroy());
|
||||
polylinesRef.current = [];
|
||||
if (mapInstanceRef.current) {
|
||||
mapInstanceRef.current.destroy();
|
||||
mapInstanceRef.current = null;
|
||||
@@ -244,9 +320,7 @@ export function TravelMap({
|
||||
mapInstanceRef.current.setZoom(12);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
// geolocation denied or unavailable
|
||||
},
|
||||
() => {},
|
||||
{ enableHighAccuracy: false, timeout: 10000, maximumAge: 300000 },
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -264,6 +338,41 @@ export function TravelMap({
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const effectiveRouteDirection = routeDirection || fallbackDirection;
|
||||
|
||||
useEffect(() => {
|
||||
if (routeDirection) {
|
||||
setFallbackDirection(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const validPoints = route.filter((p) => p.lat !== 0 && p.lng !== 0 && p.lat && p.lng);
|
||||
if (validPoints.length < 2) {
|
||||
setFallbackDirection(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const key = validPoints.map((p) => `${p.lat.toFixed(5)},${p.lng.toFixed(5)}`).join('|');
|
||||
if (fallbackRequestRef.current === key) return;
|
||||
fallbackRequestRef.current = key;
|
||||
|
||||
const geoPoints: GeoLocation[] = validPoints.map((p) => ({
|
||||
lat: p.lat,
|
||||
lng: p.lng,
|
||||
name: p.name,
|
||||
}));
|
||||
|
||||
getRoute(geoPoints).then((rd) => {
|
||||
if (fallbackRequestRef.current === key) {
|
||||
setFallbackDirection(rd);
|
||||
}
|
||||
}).catch(() => {
|
||||
if (fallbackRequestRef.current === key) {
|
||||
setFallbackDirection(null);
|
||||
}
|
||||
});
|
||||
}, [route, routeDirection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMapReady || !mapInstanceRef.current || !mapglRef.current) return;
|
||||
|
||||
@@ -273,10 +382,8 @@ export function TravelMap({
|
||||
markersRef.current.forEach((m) => m.destroy());
|
||||
markersRef.current = [];
|
||||
|
||||
if (polylineRef.current) {
|
||||
polylineRef.current.destroy();
|
||||
polylineRef.current = null;
|
||||
}
|
||||
polylinesRef.current.forEach((p) => p.destroy());
|
||||
polylinesRef.current = [];
|
||||
|
||||
if (userMarkerRef.current) {
|
||||
userMarkerRef.current.destroy();
|
||||
@@ -289,11 +396,11 @@ export function TravelMap({
|
||||
coordinates: [effectiveUserLocation.lng, effectiveUserLocation.lat],
|
||||
label: {
|
||||
text: '',
|
||||
offset: [0, -48],
|
||||
offset: [0, -40],
|
||||
image: {
|
||||
url: `data:image/svg+xml,${encodeURIComponent(createUserLocationSVG())}`,
|
||||
size: [32, 32],
|
||||
anchor: [16, 16],
|
||||
size: [28, 28],
|
||||
anchor: [14, 14],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -311,21 +418,27 @@ export function TravelMap({
|
||||
return;
|
||||
}
|
||||
|
||||
route.forEach((point, index) => {
|
||||
let pointIndex = 0;
|
||||
route.forEach((point) => {
|
||||
if (!point.lat || !point.lng || point.lat === 0 || point.lng === 0) return;
|
||||
|
||||
pointIndex++;
|
||||
const color = pointTypeColors[point.type] || pointTypeColors.custom || '#EC4899';
|
||||
const isItinerary = ITINERARY_TYPES.has(point.type);
|
||||
const svgUrl = `data:image/svg+xml,${encodeURIComponent(createNumberedPinMarkerSVG(pointIndex, color))}`;
|
||||
const markerSize: [number, number] = [26, 34];
|
||||
const markerAnchor: [number, number] = [13, 32];
|
||||
|
||||
try {
|
||||
const marker = new mapgl.Marker(map, {
|
||||
coordinates: [point.lng, point.lat],
|
||||
label: {
|
||||
text: String(index + 1),
|
||||
offset: [0, -48],
|
||||
text: '',
|
||||
offset: [0, isItinerary ? -48 : -48],
|
||||
image: {
|
||||
url: `data:image/svg+xml,${encodeURIComponent(createMarkerSVG(index + 1, color))}`,
|
||||
size: [28, 28],
|
||||
anchor: [14, 14],
|
||||
url: svgUrl,
|
||||
size: markerSize,
|
||||
anchor: markerAnchor,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -341,35 +454,61 @@ export function TravelMap({
|
||||
}
|
||||
});
|
||||
|
||||
const rdCoords = routeDirection?.geometry?.coordinates;
|
||||
const rdCoords = effectiveRouteDirection?.geometry?.coordinates;
|
||||
if (rdCoords && Array.isArray(rdCoords) && rdCoords.length > 1) {
|
||||
setRouteType('road');
|
||||
const coords = rdCoords.map(
|
||||
(c: number[]) => [c[0], c[1]] as [number, number]
|
||||
);
|
||||
try {
|
||||
polylineRef.current = new mapgl.Polyline(map, {
|
||||
const outlinePoly = new mapgl.Polyline(map, {
|
||||
coordinates: coords,
|
||||
color: '#6366F1',
|
||||
color: '#ffffff',
|
||||
width: 7,
|
||||
});
|
||||
polylinesRef.current.push(outlinePoly);
|
||||
} catch {
|
||||
// outline polyline failed
|
||||
}
|
||||
try {
|
||||
const mainPoly = new mapgl.Polyline(map, {
|
||||
coordinates: coords,
|
||||
color: '#4F5BD5',
|
||||
width: 4,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[TravelMap] road polyline failed:', err, 'coords sample:', coords.slice(0, 3));
|
||||
polylinesRef.current.push(mainPoly);
|
||||
} catch {
|
||||
// main polyline failed
|
||||
}
|
||||
} else if (route.length > 1) {
|
||||
setRouteType('straight');
|
||||
const coords = route
|
||||
.filter((p) => p.lat !== 0 && p.lng !== 0)
|
||||
.map((p) => [p.lng, p.lat] as [number, number]);
|
||||
if (coords.length > 1) {
|
||||
try {
|
||||
polylineRef.current = new mapgl.Polyline(map, {
|
||||
const outlinePoly = new mapgl.Polyline(map, {
|
||||
coordinates: coords,
|
||||
color: '#6366F1',
|
||||
color: '#ffffff',
|
||||
width: 6,
|
||||
});
|
||||
polylinesRef.current.push(outlinePoly);
|
||||
} catch {
|
||||
// outline polyline failed
|
||||
}
|
||||
try {
|
||||
const mainPoly = new mapgl.Polyline(map, {
|
||||
coordinates: coords,
|
||||
color: '#94A3B8',
|
||||
width: 3,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[TravelMap] fallback polyline failed:', err);
|
||||
polylinesRef.current.push(mainPoly);
|
||||
} catch {
|
||||
// main polyline failed
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setRouteType('none');
|
||||
}
|
||||
|
||||
const allPoints: { lat: number; lng: number }[] = route
|
||||
@@ -398,13 +537,13 @@ export function TravelMap({
|
||||
} else {
|
||||
map.fitBounds(
|
||||
{
|
||||
southWest: [minLng - lngSpan * 0.1, minLat - latSpan * 0.1],
|
||||
northEast: [maxLng + lngSpan * 0.1, maxLat + latSpan * 0.1],
|
||||
southWest: [minLng - lngSpan * 0.12, minLat - latSpan * 0.12],
|
||||
northEast: [maxLng + lngSpan * 0.12, maxLat + latSpan * 0.12],
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [isMapReady, route, routeDirection, effectiveUserLocation]);
|
||||
}, [isMapReady, route, effectiveRouteDirection, effectiveUserLocation]);
|
||||
|
||||
const handleZoomIn = useCallback(() => {
|
||||
const map = mapInstanceRef.current;
|
||||
@@ -469,8 +608,8 @@ export function TravelMap({
|
||||
mapInstanceRef.current.setZoom(14);
|
||||
} else {
|
||||
mapInstanceRef.current.fitBounds({
|
||||
southWest: [minLng - lngSpan * 0.1, minLat - latSpan * 0.1],
|
||||
northEast: [maxLng + lngSpan * 0.1, maxLat + latSpan * 0.1],
|
||||
southWest: [minLng - lngSpan * 0.12, minLat - latSpan * 0.12],
|
||||
northEast: [maxLng + lngSpan * 0.12, maxLat + latSpan * 0.12],
|
||||
});
|
||||
}
|
||||
}, [route, effectiveUserLocation]);
|
||||
@@ -479,90 +618,187 @@ export function TravelMap({
|
||||
? pointTypeIcons[selectedPoint.type] || MapPin
|
||||
: MapPin;
|
||||
|
||||
const selectedPointIndex = useMemo(() => {
|
||||
if (!selectedPoint) return 0;
|
||||
if (!selectedPoint.id) return 0;
|
||||
return pointNumberById.get(selectedPoint.id) || 0;
|
||||
}, [selectedPoint, pointNumberById]);
|
||||
|
||||
return (
|
||||
<div className={`relative rounded-xl overflow-hidden ${className}`}>
|
||||
<div ref={mapRef} className="w-full h-full min-h-[300px]" />
|
||||
|
||||
{showControls && (
|
||||
<div className="absolute top-4 right-4 flex flex-col gap-2 z-[1000]">
|
||||
<div className="absolute top-3 right-3 flex flex-col gap-1.5 z-30">
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
className="w-10 h-10 bg-elevated/90 backdrop-blur-sm border border-border/50 rounded-lg flex items-center justify-center text-secondary hover:text-primary hover:bg-surface transition-all"
|
||||
className="w-8 h-8 bg-elevated/90 backdrop-blur-sm border border-border/40 rounded-lg flex items-center justify-center text-secondary hover:text-primary hover:bg-surface transition-all"
|
||||
title="Увеличить"
|
||||
>
|
||||
<ZoomIn className="w-5 h-5" />
|
||||
<ZoomIn className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
className="w-10 h-10 bg-elevated/90 backdrop-blur-sm border border-border/50 rounded-lg flex items-center justify-center text-secondary hover:text-primary hover:bg-surface transition-all"
|
||||
className="w-8 h-8 bg-elevated/90 backdrop-blur-sm border border-border/40 rounded-lg flex items-center justify-center text-secondary hover:text-primary hover:bg-surface transition-all"
|
||||
title="Уменьшить"
|
||||
>
|
||||
<ZoomOut className="w-5 h-5" />
|
||||
<ZoomOut className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLocate}
|
||||
className="w-10 h-10 bg-elevated/90 backdrop-blur-sm border border-border/50 rounded-lg flex items-center justify-center text-secondary hover:text-primary hover:bg-surface transition-all"
|
||||
className="w-8 h-8 bg-elevated/90 backdrop-blur-sm border border-border/40 rounded-lg flex items-center justify-center text-secondary hover:text-primary hover:bg-surface transition-all"
|
||||
title="Моё местоположение"
|
||||
>
|
||||
<Locate className="w-5 h-5" />
|
||||
<Locate className="w-4 h-4" />
|
||||
</button>
|
||||
{route.length > 1 && (
|
||||
<button
|
||||
onClick={handleFitRoute}
|
||||
className="w-10 h-10 bg-elevated/90 backdrop-blur-sm border border-border/50 rounded-lg flex items-center justify-center text-secondary hover:text-primary hover:bg-surface transition-all"
|
||||
className="w-8 h-8 bg-elevated/90 backdrop-blur-sm border border-border/40 rounded-lg flex items-center justify-center text-secondary hover:text-primary hover:bg-surface transition-all"
|
||||
title="Показать весь маршрут"
|
||||
>
|
||||
<Navigation className="w-5 h-5" />
|
||||
<Navigation className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{activeTypes.size > 1 && (
|
||||
<button
|
||||
onClick={() => setShowLegend((v) => !v)}
|
||||
className={`w-8 h-8 backdrop-blur-sm border rounded-lg flex items-center justify-center transition-all ${
|
||||
showLegend
|
||||
? 'bg-accent/20 border-accent/40 text-accent'
|
||||
: 'bg-elevated/90 border-border/40 text-secondary hover:text-primary hover:bg-surface'
|
||||
}`}
|
||||
title="Легенда"
|
||||
>
|
||||
<Layers className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{route.length > 0 && (
|
||||
<div className="absolute top-3 left-3 z-30 flex flex-col gap-1">
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1.5 bg-elevated/90 backdrop-blur-sm border border-border/40 rounded-lg">
|
||||
<MapPin className="w-3 h-3 text-accent" />
|
||||
<span className="text-[11px] font-medium text-primary">{route.length}</span>
|
||||
<span className="text-[10px] text-muted">
|
||||
{route.length === 1 ? 'точка' : route.length < 5 ? 'точки' : 'точек'}
|
||||
</span>
|
||||
</div>
|
||||
{routeType === 'road' && (
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-green-500/10 backdrop-blur-sm border border-green-500/30 rounded-lg">
|
||||
<Navigation className="w-2.5 h-2.5 text-green-500" />
|
||||
<span className="text-[9px] text-green-500 font-medium">По дорогам</span>
|
||||
</div>
|
||||
)}
|
||||
{routeType === 'straight' && (
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-yellow-500/10 backdrop-blur-sm border border-yellow-500/30 rounded-lg">
|
||||
<Navigation className="w-2.5 h-2.5 text-yellow-500" />
|
||||
<span className="text-[9px] text-yellow-500 font-medium">Прямые линии</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legend */}
|
||||
<AnimatePresence>
|
||||
{showLegend && activeTypes.size > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 10 }}
|
||||
className="absolute top-14 right-3 z-30 bg-elevated/95 backdrop-blur-sm border border-border/40 rounded-lg p-2 min-w-[120px]"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{Array.from(activeTypes).map((type) => (
|
||||
<div key={type} className="flex items-center gap-2 px-1 py-0.5">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-sm flex-shrink-0"
|
||||
style={{ backgroundColor: pointTypeColors[type] || '#94A3B8' }}
|
||||
/>
|
||||
<span className="text-[10px] text-secondary leading-none">
|
||||
{pointTypeLabels[type] || type}
|
||||
</span>
|
||||
<span className="text-[9px] text-muted ml-auto">
|
||||
{route.filter((p) => p.type === type).length}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Selected point popup */}
|
||||
<AnimatePresence>
|
||||
{selectedPoint && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
className="absolute bottom-4 left-4 right-4 bg-elevated/95 backdrop-blur-sm border border-border/50 rounded-xl p-4 z-[1000]"
|
||||
exit={{ opacity: 0, y: 16 }}
|
||||
className="absolute bottom-3 left-3 right-3 bg-elevated/95 backdrop-blur-sm border border-border/40 rounded-xl overflow-hidden z-30"
|
||||
>
|
||||
<button
|
||||
onClick={() => setSelectedPoint(null)}
|
||||
className="absolute top-3 right-3 p-1 rounded-lg hover:bg-surface/50 text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center"
|
||||
style={{ backgroundColor: pointTypeColors[selectedPoint.type] + '20' }}
|
||||
<div className="p-3">
|
||||
<button
|
||||
onClick={() => setSelectedPoint(null)}
|
||||
className="absolute top-2.5 right-2.5 p-1 rounded-md hover:bg-surface/50 text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
<PointIcon
|
||||
className="w-5 h-5"
|
||||
style={{ color: pointTypeColors[selectedPoint.type] }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-sm font-medium text-primary truncate">
|
||||
{selectedPoint.name}
|
||||
</h4>
|
||||
{selectedPoint.address && (
|
||||
<p className="text-xs text-muted mt-0.5 truncate">
|
||||
{selectedPoint.address}
|
||||
</p>
|
||||
)}
|
||||
{selectedPoint.aiComment && (
|
||||
<div className="flex items-start gap-2 mt-2 p-2 bg-surface/50 rounded-lg">
|
||||
<Sparkles className="w-4 h-4 text-accent flex-shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-secondary">{selectedPoint.aiComment}</p>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-start gap-2.5 pr-6">
|
||||
<div
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ backgroundColor: pointTypeColors[selectedPoint.type] + '18' }}
|
||||
>
|
||||
<PointIcon
|
||||
className="w-4 h-4"
|
||||
style={{ color: pointTypeColors[selectedPoint.type] }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedPointIndex > 0 && (
|
||||
<span
|
||||
className="w-4.5 h-4.5 rounded text-[9px] font-bold flex items-center justify-center text-white px-1"
|
||||
style={{ backgroundColor: pointTypeColors[selectedPoint.type] }}
|
||||
>
|
||||
{selectedPointIndex}
|
||||
</span>
|
||||
)}
|
||||
<h4 className="text-[13px] font-medium text-primary truncate">
|
||||
{selectedPoint.name}
|
||||
</h4>
|
||||
</div>
|
||||
)}
|
||||
{selectedPoint.duration && (
|
||||
<p className="text-xs text-muted mt-2">
|
||||
Рекомендуемое время: {selectedPoint.duration} мин
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||
<span
|
||||
className="text-[9px] font-medium px-1.5 py-px rounded-full"
|
||||
style={{
|
||||
backgroundColor: pointTypeColors[selectedPoint.type] + '15',
|
||||
color: pointTypeColors[selectedPoint.type],
|
||||
}}
|
||||
>
|
||||
{pointTypeLabels[selectedPoint.type] || selectedPoint.type}
|
||||
</span>
|
||||
{selectedPoint.address && (
|
||||
<span className="text-[10px] text-muted truncate max-w-[180px]">
|
||||
{selectedPoint.address}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{selectedPoint.aiComment && (
|
||||
<div className="flex items-start gap-1.5 mt-2 p-2 bg-accent/5 border border-accent/10 rounded-lg">
|
||||
<Sparkles className="w-3 h-3 text-accent flex-shrink-0 mt-0.5" />
|
||||
<p className="text-[10px] text-secondary leading-relaxed">{selectedPoint.aiComment}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedPoint.duration && selectedPoint.duration > 0 && (
|
||||
<div className="flex items-center gap-1 mt-1.5">
|
||||
<Clock className="w-3 h-3 text-muted" />
|
||||
<span className="text-[10px] text-muted">~{selectedPoint.duration} мин</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
649
backend/webui/src/components/TravelWidgetTabs.tsx
Normal file
649
backend/webui/src/components/TravelWidgetTabs.tsx
Normal file
@@ -0,0 +1,649 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Calendar,
|
||||
Ticket,
|
||||
Hotel,
|
||||
Plane,
|
||||
Bus,
|
||||
DollarSign,
|
||||
CloudSun,
|
||||
Camera,
|
||||
Search,
|
||||
Filter,
|
||||
Sparkles,
|
||||
Check,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { TravelWidgetRenderer } from '@/components/TravelWidgets';
|
||||
import type { TravelWidget } from '@/lib/hooks/useTravelChat';
|
||||
import type {
|
||||
EventCard,
|
||||
POICard,
|
||||
HotelCard,
|
||||
TransportOption,
|
||||
ItineraryDay,
|
||||
} from '@/lib/types';
|
||||
import type { LLMValidationResponse } from '@/lib/hooks/useEditableItinerary';
|
||||
|
||||
type TabId = 'plan' | 'places' | 'events' | 'hotels' | 'tickets' | 'transport' | 'budget' | 'context';
|
||||
|
||||
interface TabDef {
|
||||
id: TabId;
|
||||
label: string;
|
||||
icon: typeof Calendar;
|
||||
widgetTypes: string[];
|
||||
emptyLabel: string;
|
||||
}
|
||||
|
||||
const TABS: TabDef[] = [
|
||||
{ id: 'plan', label: 'План', icon: Calendar, widgetTypes: ['travel_itinerary'], emptyLabel: 'Маршрут ещё не построен' },
|
||||
{ id: 'places', label: 'Места', icon: Camera, widgetTypes: ['travel_poi'], emptyLabel: 'Достопримечательности не найдены' },
|
||||
{ id: 'events', label: 'События', icon: Ticket, widgetTypes: ['travel_events'], emptyLabel: 'Мероприятия не найдены' },
|
||||
{ id: 'hotels', label: 'Отели', icon: Hotel, widgetTypes: ['travel_hotels'], emptyLabel: 'Отели не найдены' },
|
||||
{ id: 'tickets', label: 'Билеты', icon: Plane, widgetTypes: ['travel_transport'], emptyLabel: 'Билеты не найдены' },
|
||||
{ id: 'transport', label: 'Транспорт', icon: Bus, widgetTypes: ['travel_transport'], emptyLabel: 'Транспорт не найден' },
|
||||
{ id: 'budget', label: 'Бюджет', icon: DollarSign, widgetTypes: ['travel_budget'], emptyLabel: 'Бюджет не рассчитан' },
|
||||
{ id: 'context', label: 'Инфо', icon: CloudSun, widgetTypes: ['travel_context'], emptyLabel: 'Информация недоступна' },
|
||||
];
|
||||
|
||||
type LoadingPhase = 'idle' | 'planning' | 'collecting' | 'building' | 'routing';
|
||||
|
||||
interface TravelWidgetTabsProps {
|
||||
widgets: TravelWidget[];
|
||||
onAddEventToMap?: (event: EventCard) => void;
|
||||
onAddPOIToMap?: (poi: POICard) => void;
|
||||
onSelectHotel?: (hotel: HotelCard) => void;
|
||||
onSelectTransport?: (option: TransportOption) => void;
|
||||
onClarifyingAnswer?: (field: string, value: string) => void;
|
||||
onAction?: (kind: string) => void;
|
||||
selectedEventIds?: Set<string>;
|
||||
selectedPOIIds?: Set<string>;
|
||||
selectedHotelId?: string;
|
||||
selectedTransportId?: string;
|
||||
availablePois?: POICard[];
|
||||
availableEvents?: EventCard[];
|
||||
onItineraryUpdate?: (days: ItineraryDay[]) => void;
|
||||
onValidateItineraryWithLLM?: (days: ItineraryDay[]) => Promise<LLMValidationResponse | null>;
|
||||
isLoading?: boolean;
|
||||
loadingPhase?: LoadingPhase;
|
||||
isResearching?: boolean;
|
||||
routePointCount?: number;
|
||||
hasRouteDirection?: boolean;
|
||||
}
|
||||
|
||||
function normalizeText(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function includesAny(haystack: string, needles: string[]): boolean {
|
||||
const h = normalizeText(haystack);
|
||||
if (!h) return false;
|
||||
return needles.some((n) => h.includes(n));
|
||||
}
|
||||
|
||||
function getEventSearchText(e: EventCard): string {
|
||||
return [
|
||||
e.title,
|
||||
e.description,
|
||||
e.address,
|
||||
e.source,
|
||||
...(e.tags || []),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function getPOISearchText(p: POICard): string {
|
||||
return [p.name, p.description, p.address, p.category].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
function getHotelSearchText(h: HotelCard): string {
|
||||
return [h.name, h.address, ...(h.amenities || [])].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
function getTransportSearchText(t: TransportOption): string {
|
||||
return [
|
||||
t.mode,
|
||||
t.from,
|
||||
t.to,
|
||||
t.provider,
|
||||
t.airline,
|
||||
t.flightNum,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function TabToolbar({
|
||||
tab,
|
||||
query,
|
||||
setQuery,
|
||||
onlySelected,
|
||||
setOnlySelected,
|
||||
minRating,
|
||||
setMinRating,
|
||||
filtersOpen,
|
||||
setFiltersOpen,
|
||||
isLoading,
|
||||
onAskMore,
|
||||
showAskMore,
|
||||
}: {
|
||||
tab: TabId;
|
||||
query: string;
|
||||
setQuery: (value: string) => void;
|
||||
onlySelected: boolean;
|
||||
setOnlySelected: (value: boolean) => void;
|
||||
minRating: number;
|
||||
setMinRating: (value: number) => void;
|
||||
filtersOpen: boolean;
|
||||
setFiltersOpen: (value: boolean) => void;
|
||||
isLoading?: boolean;
|
||||
onAskMore?: () => void;
|
||||
showAskMore?: boolean;
|
||||
}) {
|
||||
const placeholder =
|
||||
tab === 'plan'
|
||||
? 'Поиск по маршруту...'
|
||||
: tab === 'places'
|
||||
? 'Поиск по местам...'
|
||||
: tab === 'events'
|
||||
? 'Поиск по событиям...'
|
||||
: tab === 'hotels'
|
||||
? 'Поиск по отелям...'
|
||||
: tab === 'tickets'
|
||||
? 'Поиск по билетам...'
|
||||
: tab === 'transport'
|
||||
? 'Поиск по транспорту...'
|
||||
: tab === 'budget'
|
||||
? 'Поиск по бюджету...'
|
||||
: 'Поиск...';
|
||||
|
||||
const showRating = tab === 'places' || tab === 'hotels';
|
||||
const showSelected = tab === 'places' || tab === 'events' || tab === 'hotels' || tab === 'tickets' || tab === 'transport';
|
||||
const filterCount = (showSelected && onlySelected ? 1 : 0) + (showRating && minRating > 0 ? 1 : 0);
|
||||
const shouldShowFilterPanel = filtersOpen || filterCount > 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="w-3.5 h-3.5 text-muted absolute left-2.5 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full pl-8 pr-3 py-2 text-xs bg-surface/40 border border-border/30 rounded-lg text-primary placeholder:text-muted focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||
/>
|
||||
</div>
|
||||
{onAskMore && showAskMore && (
|
||||
<button
|
||||
onClick={onAskMore}
|
||||
disabled={isLoading}
|
||||
className={`w-9 h-9 flex items-center justify-center rounded-lg border transition-colors ${
|
||||
isLoading
|
||||
? 'bg-surface/30 text-muted border-border/20'
|
||||
: 'bg-accent/15 text-accent border-accent/25 hover:bg-accent/20'
|
||||
}`}
|
||||
title="Попросить AI найти ещё варианты"
|
||||
aria-label="Найти ещё"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setFiltersOpen(!filtersOpen)}
|
||||
className={`w-9 h-9 flex items-center justify-center rounded-lg border transition-colors ${
|
||||
shouldShowFilterPanel
|
||||
? 'bg-surface/50 text-secondary border-border/40'
|
||||
: 'bg-surface/30 text-muted border-border/25 hover:text-secondary'
|
||||
}`}
|
||||
title="Фильтры"
|
||||
aria-label="Фильтры"
|
||||
>
|
||||
<div className="relative">
|
||||
<Filter className="w-4 h-4" />
|
||||
{filterCount > 0 && (
|
||||
<span className="absolute -top-1.5 -right-1.5 min-w-[14px] h-[14px] px-1 rounded-full bg-accent text-3xs leading-[14px] text-black text-center">
|
||||
{filterCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{shouldShowFilterPanel && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{showSelected && (
|
||||
<button
|
||||
onClick={() => setOnlySelected(!onlySelected)}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1 text-ui-sm rounded-lg border transition-colors ${
|
||||
onlySelected
|
||||
? 'bg-accent/20 text-accent border-accent/30'
|
||||
: 'bg-surface/30 text-secondary border-border/30 hover:text-primary'
|
||||
}`}
|
||||
title="Показывать только выбранные"
|
||||
>
|
||||
{onlySelected && <Check className="w-3.5 h-3.5" />}
|
||||
Выбранные
|
||||
</button>
|
||||
)}
|
||||
{showRating && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-ui-sm text-muted">Рейтинг</span>
|
||||
<select
|
||||
value={minRating}
|
||||
onChange={(e) => setMinRating(Number(e.target.value))}
|
||||
className="px-2 py-1 text-ui-sm bg-surface/30 border border-border/30 rounded-lg text-secondary focus:outline-none"
|
||||
aria-label="Минимальный рейтинг"
|
||||
>
|
||||
<option value={0}>Любой</option>
|
||||
<option value={4}>4.0+</option>
|
||||
<option value={4.5}>4.5+</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{!showSelected && !showRating && (
|
||||
<span className="text-ui-sm text-muted/80">Нет фильтров для этого этапа</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TravelWidgetTabs({
|
||||
widgets,
|
||||
onAddEventToMap,
|
||||
onAddPOIToMap,
|
||||
onSelectHotel,
|
||||
onSelectTransport,
|
||||
onClarifyingAnswer,
|
||||
onAction,
|
||||
selectedEventIds = new Set(),
|
||||
selectedPOIIds = new Set(),
|
||||
selectedHotelId,
|
||||
selectedTransportId,
|
||||
availablePois,
|
||||
availableEvents,
|
||||
onItineraryUpdate,
|
||||
onValidateItineraryWithLLM,
|
||||
isLoading,
|
||||
loadingPhase = 'idle',
|
||||
isResearching,
|
||||
routePointCount = 0,
|
||||
hasRouteDirection,
|
||||
}: TravelWidgetTabsProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabId>('plan');
|
||||
const [inlineNotice, setInlineNotice] = useState<string | null>(null);
|
||||
const [queryByTab, setQueryByTab] = useState<Record<TabId, string>>({
|
||||
plan: '',
|
||||
places: '',
|
||||
events: '',
|
||||
hotels: '',
|
||||
tickets: '',
|
||||
transport: '',
|
||||
budget: '',
|
||||
context: '',
|
||||
});
|
||||
const [onlySelectedByTab, setOnlySelectedByTab] = useState<Record<TabId, boolean>>({
|
||||
plan: false,
|
||||
places: false,
|
||||
events: false,
|
||||
hotels: false,
|
||||
tickets: false,
|
||||
transport: false,
|
||||
budget: false,
|
||||
context: false,
|
||||
});
|
||||
const [minRatingByTab, setMinRatingByTab] = useState<Record<TabId, number>>({
|
||||
plan: 0,
|
||||
places: 0,
|
||||
events: 0,
|
||||
hotels: 0,
|
||||
tickets: 0,
|
||||
transport: 0,
|
||||
budget: 0,
|
||||
context: 0,
|
||||
});
|
||||
const [filtersOpenByTab, setFiltersOpenByTab] = useState<Record<TabId, boolean>>({
|
||||
plan: false,
|
||||
places: false,
|
||||
events: false,
|
||||
hotels: false,
|
||||
tickets: false,
|
||||
transport: false,
|
||||
budget: false,
|
||||
context: false,
|
||||
});
|
||||
|
||||
const travelWidgets = useMemo(
|
||||
() => widgets.filter((w) => w.type.startsWith('travel_')),
|
||||
[widgets],
|
||||
);
|
||||
|
||||
const widgetsByTab = useMemo(() => {
|
||||
const map = new Map<TabId, TravelWidget[]>();
|
||||
for (const tab of TABS) {
|
||||
map.set(tab.id, []);
|
||||
}
|
||||
for (const w of travelWidgets) {
|
||||
for (const tab of TABS) {
|
||||
if (tab.widgetTypes.includes(w.type)) {
|
||||
map.get(tab.id)!.push(w);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [travelWidgets]);
|
||||
|
||||
const tabCounts = useMemo(() => {
|
||||
const counts = new Map<TabId, number>();
|
||||
for (const tab of TABS) {
|
||||
const tabWidgets = widgetsByTab.get(tab.id) || [];
|
||||
let count = 0;
|
||||
for (const w of tabWidgets) {
|
||||
const p = w.params;
|
||||
if (p.days) count += (p.days as ItineraryDay[]).length;
|
||||
else if (p.pois) count += (p.pois as POICard[]).length;
|
||||
else if (p.events) count += (p.events as EventCard[]).length;
|
||||
else if (p.hotels) count += (p.hotels as HotelCard[]).length;
|
||||
else if (p.flights || p.ground) {
|
||||
const flightsCount = ((p.flights as TransportOption[]) || []).length;
|
||||
const groundCount = ((p.ground as TransportOption[]) || []).length;
|
||||
if (tab.id === 'tickets') count += flightsCount;
|
||||
else if (tab.id === 'transport') count += groundCount;
|
||||
else count += flightsCount + groundCount;
|
||||
}
|
||||
else if (p.breakdown) count = 1;
|
||||
else if (p.weather || p.safety) count = 1;
|
||||
}
|
||||
counts.set(tab.id, count);
|
||||
}
|
||||
return counts;
|
||||
}, [widgetsByTab]);
|
||||
|
||||
const activeWidgets = widgetsByTab.get(activeTab) || [];
|
||||
const activeTabDef = TABS.find((t) => t.id === activeTab)!;
|
||||
|
||||
const clarifyingWidgets = useMemo(
|
||||
() => travelWidgets.filter((w) => w.type === 'travel_clarifying'),
|
||||
[travelWidgets],
|
||||
);
|
||||
|
||||
const clarifyingKey = useMemo(() => clarifyingWidgets.map((w) => w.id).join('|'), [clarifyingWidgets]);
|
||||
|
||||
useEffect(() => {
|
||||
// If clarifying widget changes, show it again and clear old notice
|
||||
setInlineNotice(null);
|
||||
}, [clarifyingKey]);
|
||||
|
||||
useEffect(() => {
|
||||
// Keep the "submitted" notice only until planning really starts.
|
||||
if (!inlineNotice) return;
|
||||
if (!isLoading) return;
|
||||
if (loadingPhase !== 'planning') {
|
||||
setInlineNotice(null);
|
||||
}
|
||||
}, [inlineNotice, isLoading, loadingPhase]);
|
||||
|
||||
const handleTabClick = useCallback((tabId: TabId) => {
|
||||
setActiveTab(tabId);
|
||||
}, []);
|
||||
|
||||
const query = queryByTab[activeTab] || '';
|
||||
const queryTokens = useMemo(() => normalizeText(query).split(/\s+/).filter(Boolean), [query]);
|
||||
const onlySelected = onlySelectedByTab[activeTab] || false;
|
||||
const minRating = minRatingByTab[activeTab] || 0;
|
||||
|
||||
const filteredActiveWidgets = useMemo(() => {
|
||||
const widgetsForTab = activeWidgets;
|
||||
if (widgetsForTab.length === 0) return [];
|
||||
|
||||
const filtered: TravelWidget[] = [];
|
||||
for (const w of widgetsForTab) {
|
||||
if (activeTab === 'places' && w.type === 'travel_poi') {
|
||||
const pois = (w.params.pois || []) as POICard[];
|
||||
const list = pois
|
||||
.filter((p) => (minRating > 0 ? (p.rating || 0) >= minRating : true))
|
||||
.filter((p) => (onlySelected ? selectedPOIIds.has(p.id) : true))
|
||||
.filter((p) => (queryTokens.length ? includesAny(getPOISearchText(p), queryTokens) : true));
|
||||
if (list.length > 0) filtered.push({ ...w, params: { ...w.params, pois: list } });
|
||||
continue;
|
||||
}
|
||||
if (activeTab === 'events' && w.type === 'travel_events') {
|
||||
const events = (w.params.events || []) as EventCard[];
|
||||
const list = events
|
||||
.filter((e) => (onlySelected ? selectedEventIds.has(e.id) : true))
|
||||
.filter((e) => (queryTokens.length ? includesAny(getEventSearchText(e), queryTokens) : true));
|
||||
if (list.length > 0) filtered.push({ ...w, params: { ...w.params, events: list } });
|
||||
continue;
|
||||
}
|
||||
if (activeTab === 'hotels' && w.type === 'travel_hotels') {
|
||||
const hotels = (w.params.hotels || []) as HotelCard[];
|
||||
const list = hotels
|
||||
.filter((h) => (minRating > 0 ? (h.rating || 0) >= minRating : true))
|
||||
.filter((h) => (onlySelected ? selectedHotelId === h.id : true))
|
||||
.filter((h) => (queryTokens.length ? includesAny(getHotelSearchText(h), queryTokens) : true));
|
||||
if (list.length > 0) filtered.push({ ...w, params: { ...w.params, hotels: list } });
|
||||
continue;
|
||||
}
|
||||
if ((activeTab === 'tickets' || activeTab === 'transport') && w.type === 'travel_transport') {
|
||||
const flights = (w.params.flights || []) as TransportOption[];
|
||||
const ground = (w.params.ground || []) as TransportOption[];
|
||||
const baseList = activeTab === 'tickets' ? flights : ground;
|
||||
const list = baseList
|
||||
.filter((t) => (onlySelected ? selectedTransportId === t.id : true))
|
||||
.filter((t) => (queryTokens.length ? includesAny(getTransportSearchText(t), queryTokens) : true));
|
||||
if (list.length > 0) {
|
||||
filtered.push({
|
||||
...w,
|
||||
params: {
|
||||
...w.params,
|
||||
flights: activeTab === 'tickets' ? list : [],
|
||||
ground: activeTab === 'transport' ? list : [],
|
||||
},
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// plan/budget/context: keep as-is (we still show empty state below if nothing meaningful)
|
||||
filtered.push(w);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [
|
||||
activeWidgets,
|
||||
activeTab,
|
||||
onlySelected,
|
||||
minRating,
|
||||
queryTokens,
|
||||
selectedPOIIds,
|
||||
selectedEventIds,
|
||||
selectedHotelId,
|
||||
selectedTransportId,
|
||||
]);
|
||||
|
||||
const hasRenderableContent = useMemo(() => {
|
||||
if (activeTab === 'plan') {
|
||||
// treat itinerary as present only when it has days
|
||||
for (const w of activeWidgets) {
|
||||
if (w.type === 'travel_itinerary' && Array.isArray(w.params.days) && (w.params.days as ItineraryDay[]).length > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (activeTab === 'budget') {
|
||||
return filteredActiveWidgets.some((w) => Boolean(w.params.breakdown));
|
||||
}
|
||||
if (activeTab === 'context') {
|
||||
return filteredActiveWidgets.some((w) => Boolean(w.params.weather || w.params.safety || w.params.tips));
|
||||
}
|
||||
return filteredActiveWidgets.length > 0;
|
||||
}, [activeTab, activeWidgets, filteredActiveWidgets]);
|
||||
|
||||
const askMore = useCallback(() => {
|
||||
if (!onAction) return;
|
||||
const q = queryByTab[activeTab]?.trim();
|
||||
const encoded = q ? `search:${activeTab}:${q}` : `search:${activeTab}`;
|
||||
onAction(encoded);
|
||||
}, [activeTab, onAction, queryByTab]);
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 rounded-xl border border-border/50 bg-base overflow-hidden flex flex-col shadow-sm">
|
||||
{!inlineNotice && clarifyingWidgets.map((w) => (
|
||||
<div key={w.id} className="p-3 border-b border-border/20">
|
||||
<TravelWidgetRenderer
|
||||
widget={w}
|
||||
onClarifyingAnswer={onClarifyingAnswer}
|
||||
onInlineNotice={(text) => setInlineNotice(text)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="px-3 py-2 border-b border-border/30 bg-surface">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 text-ui-sm text-muted">
|
||||
{inlineNotice ? (
|
||||
<>
|
||||
<Check className="w-3.5 h-3.5 text-green-600" />
|
||||
<span className="text-green-600">{inlineNotice}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isLoading && <Loader2 className="w-3.5 h-3.5 animate-spin text-accent" />}
|
||||
<span className="text-secondary">
|
||||
{isLoading && loadingPhase === 'planning' && 'Анализ запроса'}
|
||||
{isLoading && loadingPhase === 'collecting' && 'Сбор данных'}
|
||||
{isLoading && loadingPhase === 'building' && 'План по дням'}
|
||||
{isLoading && loadingPhase === 'routing' && 'Маршрут по дорогам'}
|
||||
{!isLoading && isResearching && 'Подбор вариантов'}
|
||||
{!isLoading && !isResearching && 'Готово к действиям'}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-ui-sm text-muted">
|
||||
<span>
|
||||
Точек: <span className="text-secondary">{routePointCount}</span>
|
||||
</span>
|
||||
<span className="w-1 h-1 rounded-full bg-border/60" />
|
||||
<span>
|
||||
Маршрут:{' '}
|
||||
<span className="text-secondary">
|
||||
{hasRouteDirection ? 'дороги' : routePointCount >= 2 ? 'линии' : 'нет'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-11 shrink-0 flex items-center gap-0.5 px-2 pt-2 pb-0 overflow-x-auto scrollbar-hide bg-base">
|
||||
{TABS.map((tab) => {
|
||||
const count = tabCounts.get(tab.id) || 0;
|
||||
const isActive = activeTab === tab.id;
|
||||
const Icon = tab.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => handleTabClick(tab.id)}
|
||||
className={`h-9 flex items-center gap-1.5 px-3 text-ui-sm font-medium rounded-t-lg transition-all whitespace-nowrap flex-shrink-0 border-b-2 ${
|
||||
isActive
|
||||
? 'bg-surface/60 text-primary border-accent'
|
||||
: 'text-muted hover:text-secondary hover:bg-surface/30 border-transparent'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{tab.label}
|
||||
<span
|
||||
className={`min-w-[18px] h-[14px] px-1.5 text-3xs rounded-full inline-flex items-center justify-center ${
|
||||
count > 0 ? '' : 'opacity-0'
|
||||
} ${isActive ? 'bg-accent/20 text-accent' : 'bg-surface/50 text-muted'}`}
|
||||
aria-hidden={count === 0}
|
||||
>
|
||||
{count > 0 ? count : '0'}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={activeTab}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="p-3"
|
||||
>
|
||||
<TabToolbar
|
||||
tab={activeTab}
|
||||
query={queryByTab[activeTab] || ''}
|
||||
setQuery={(value) => setQueryByTab((prev) => ({ ...prev, [activeTab]: value }))}
|
||||
onlySelected={onlySelectedByTab[activeTab] || false}
|
||||
setOnlySelected={(value) => setOnlySelectedByTab((prev) => ({ ...prev, [activeTab]: value }))}
|
||||
minRating={minRatingByTab[activeTab] || 0}
|
||||
setMinRating={(value) => setMinRatingByTab((prev) => ({ ...prev, [activeTab]: value }))}
|
||||
filtersOpen={filtersOpenByTab[activeTab] || false}
|
||||
setFiltersOpen={(value) => setFiltersOpenByTab((prev) => ({ ...prev, [activeTab]: value }))}
|
||||
isLoading={isLoading}
|
||||
onAskMore={activeTab === 'budget' || activeTab === 'context' ? undefined : askMore}
|
||||
showAskMore={Boolean((queryByTab[activeTab] || '').trim())}
|
||||
/>
|
||||
|
||||
{isLoading && travelWidgets.length === 0 ? (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-muted">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span className="text-xs">Собираю данные...</span>
|
||||
</div>
|
||||
) : !hasRenderableContent ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-8 text-muted">
|
||||
<AlertCircle className="w-5 h-5 opacity-40" />
|
||||
<span className="text-xs">{activeTabDef.emptyLabel}</span>
|
||||
{(activeTab === 'tickets' || activeTab === 'transport') && (
|
||||
<span className="text-ui-sm text-muted/80 text-center max-w-[520px]">
|
||||
Билеты и транспорт появляются после планирования (или нажмите «Найти ещё», чтобы попросить AI собрать варианты).
|
||||
</span>
|
||||
)}
|
||||
{activeTab === 'plan' && (
|
||||
<span className="text-ui-sm text-muted/80 text-center max-w-[520px]">
|
||||
План строится в 4 шага: анализ запроса → сбор мест/событий/отелей → маршрут по дням → дороги на карте.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
filteredActiveWidgets.map((widget) => (
|
||||
<TravelWidgetRenderer
|
||||
key={widget.id}
|
||||
widget={widget}
|
||||
onAddEventToMap={onAddEventToMap}
|
||||
onAddPOIToMap={onAddPOIToMap}
|
||||
onSelectHotel={onSelectHotel}
|
||||
onSelectTransport={onSelectTransport}
|
||||
onClarifyingAnswer={onClarifyingAnswer}
|
||||
onAction={onAction}
|
||||
selectedEventIds={selectedEventIds}
|
||||
selectedPOIIds={selectedPOIIds}
|
||||
selectedHotelId={selectedHotelId}
|
||||
selectedTransportId={selectedTransportId}
|
||||
availablePois={availablePois}
|
||||
availableEvents={availableEvents}
|
||||
onItineraryUpdate={onItineraryUpdate}
|
||||
onValidateItineraryWithLLM={onValidateItineraryWithLLM}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,14 @@ import {
|
||||
Ticket,
|
||||
HelpCircle,
|
||||
CloudSun,
|
||||
Cloud,
|
||||
Sun,
|
||||
CloudRain,
|
||||
CloudLightning,
|
||||
Snowflake,
|
||||
CloudFog,
|
||||
Wind,
|
||||
Droplets,
|
||||
Shield,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
@@ -35,7 +43,9 @@ import {
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
Phone,
|
||||
Pencil,
|
||||
} from 'lucide-react';
|
||||
import { EditableItinerary } from '@/components/EditableItinerary';
|
||||
import type {
|
||||
EventCard,
|
||||
POICard,
|
||||
@@ -47,6 +57,8 @@ import type {
|
||||
MapPoint,
|
||||
RouteSegment,
|
||||
WeatherAssessment,
|
||||
DailyForecast,
|
||||
WeatherIcon,
|
||||
SafetyAssessment,
|
||||
RestrictionItem,
|
||||
TravelTip,
|
||||
@@ -103,7 +115,7 @@ function EventCardComponent({ event, onAddToMap, isSelected }: EventCardComponen
|
||||
{event.tags && event.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{event.tags.slice(0, 3).map((tag) => (
|
||||
<span key={tag} className="px-2 py-0.5 text-[10px] bg-surface/60 text-muted rounded-full">
|
||||
<span key={tag} className="px-2 py-0.5 text-2xs bg-surface/60 text-muted rounded-full">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
@@ -172,27 +184,20 @@ const categoryIconsMap: Record<string, typeof MapPin> = {
|
||||
|
||||
function POICardComponent({ poi, onAddToMap, isSelected }: POICardComponentProps) {
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const Icon = categoryIconsMap[poi.category] || MapPin;
|
||||
const hasPhoto = poi.photos && poi.photos.length > 0 && !imgError;
|
||||
|
||||
const scheduleEntries = useMemo(() => {
|
||||
if (!poi.schedule) return [];
|
||||
return Object.entries(poi.schedule);
|
||||
}, [poi.schedule]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
initial={{ opacity: 0, scale: 0.97 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className={`flex-shrink-0 w-[260px] rounded-xl border overflow-hidden transition-all ${
|
||||
className={`flex-shrink-0 w-[180px] rounded-xl border overflow-hidden transition-all ${
|
||||
isSelected
|
||||
? 'border-accent/50 bg-accent/10'
|
||||
: 'border-border/40 bg-elevated/40 hover:border-border'
|
||||
}`}
|
||||
>
|
||||
{/* Photo */}
|
||||
<div className="relative w-full h-[140px] bg-surface/60 overflow-hidden">
|
||||
<div className="relative w-full h-[100px] bg-surface/60 overflow-hidden">
|
||||
{hasPhoto ? (
|
||||
<img
|
||||
src={poi.photos![0]}
|
||||
@@ -203,109 +208,52 @@ function POICardComponent({ poi, onAddToMap, isSelected }: POICardComponentProps
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Icon className="w-10 h-10 text-muted/30" />
|
||||
<Icon className="w-8 h-8 text-muted/20" />
|
||||
</div>
|
||||
)}
|
||||
{/* Category badge */}
|
||||
<span className="absolute top-2 left-2 px-2 py-0.5 text-[10px] font-medium bg-black/60 text-white rounded-full backdrop-blur-sm">
|
||||
<span className="absolute top-1.5 left-1.5 px-1.5 py-px text-3xs font-medium bg-black/60 text-white rounded-full backdrop-blur-sm">
|
||||
{categoryLabels[poi.category] || poi.category}
|
||||
</span>
|
||||
{/* Add to map button */}
|
||||
{onAddToMap && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onAddToMap(poi); }}
|
||||
className="absolute top-2 right-2 p-1.5 rounded-full bg-black/50 text-white hover:bg-accent/80 transition-colors backdrop-blur-sm"
|
||||
className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/50 text-white hover:bg-accent/80 transition-colors backdrop-blur-sm"
|
||||
title="На карту"
|
||||
>
|
||||
{isSelected ? <Check className="w-3.5 h-3.5" /> : <Plus className="w-3.5 h-3.5" />}
|
||||
{isSelected ? <Check className="w-3 h-3" /> : <Plus className="w-3 h-3" />}
|
||||
</button>
|
||||
)}
|
||||
{/* Photo count */}
|
||||
{poi.photos && poi.photos.length > 1 && (
|
||||
<span className="absolute bottom-2 right-2 px-1.5 py-0.5 text-[10px] bg-black/60 text-white rounded-full backdrop-blur-sm">
|
||||
{poi.photos.length} фото
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-3">
|
||||
<h4 className="text-sm font-medium text-primary leading-tight line-clamp-2">{poi.name}</h4>
|
||||
<div className="p-2">
|
||||
<h4 className="text-ui-sm font-medium text-primary leading-tight line-clamp-2">{poi.name}</h4>
|
||||
|
||||
{/* Rating + Reviews */}
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
{(poi.rating ?? 0) > 0 && (
|
||||
<span className="flex items-center gap-1 text-xs font-medium text-amber-500">
|
||||
<Star className="w-3 h-3 fill-current" />
|
||||
<span className="flex items-center gap-0.5 text-2xs font-medium text-amber-500">
|
||||
<Star className="w-2.5 h-2.5 fill-current" />
|
||||
{poi.rating!.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
{(poi.reviewCount ?? 0) > 0 && (
|
||||
<span className="text-[10px] text-muted">
|
||||
{poi.reviewCount} отзывов
|
||||
</span>
|
||||
<span className="text-3xs text-muted">({poi.reviewCount})</span>
|
||||
)}
|
||||
{(poi.duration ?? 0) > 0 && (
|
||||
<span className="flex items-center gap-0.5 text-[10px] text-muted ml-auto">
|
||||
<Clock className="w-2.5 h-2.5" />
|
||||
{poi.duration} мин
|
||||
{(poi.price ?? 0) > 0 && (
|
||||
<span className="text-2xs font-medium text-accent ml-auto">
|
||||
{poi.price?.toLocaleString('ru-RU')} ₽
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{poi.description && (
|
||||
<p className="text-xs text-secondary mt-1.5 line-clamp-2">{poi.description}</p>
|
||||
<p className="text-2xs text-secondary mt-1 line-clamp-2 leading-snug">{poi.description}</p>
|
||||
)}
|
||||
|
||||
{/* Address */}
|
||||
{poi.address && (
|
||||
<div className="flex items-start gap-1 mt-1.5">
|
||||
<MapPin className="w-3 h-3 text-muted flex-shrink-0 mt-0.5" />
|
||||
<span className="text-[10px] text-muted line-clamp-1">{poi.address}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Price */}
|
||||
{(poi.price ?? 0) > 0 && (
|
||||
<div className="mt-1.5">
|
||||
<span className="text-xs font-medium text-accent">
|
||||
{poi.price?.toLocaleString('ru-RU')} {poi.currency || '₽'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expandable schedule */}
|
||||
{scheduleEntries.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex items-center gap-1 text-[10px] text-accent hover:text-accent/80 transition-colors"
|
||||
>
|
||||
<Clock className="w-2.5 h-2.5" />
|
||||
{expanded ? 'Скрыть расписание' : 'Расписание'}
|
||||
{expanded ? <ChevronUp className="w-2.5 h-2.5" /> : <ChevronDown className="w-2.5 h-2.5" />}
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{expanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5 mt-1.5">
|
||||
{scheduleEntries.map(([day, hours]) => (
|
||||
<div key={day} className="flex justify-between text-[10px]">
|
||||
<span className="text-muted font-medium">{day}</span>
|
||||
<span className="text-secondary">{hours}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
{(poi.duration ?? 0) > 0 && (
|
||||
<span className="flex items-center gap-0.5 text-3xs text-muted mt-1">
|
||||
<Clock className="w-2.5 h-2.5" />
|
||||
~{poi.duration} мин
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -536,6 +484,50 @@ function TransportCardComponent({ option, onSelect, isSelected }: TransportCardC
|
||||
);
|
||||
}
|
||||
|
||||
// --- Timeline Step Marker ---
|
||||
|
||||
const stepIconMap: Record<string, typeof MapPin> = {
|
||||
attraction: Camera,
|
||||
museum: Camera,
|
||||
park: MapPin,
|
||||
restaurant: Utensils,
|
||||
food: Utensils,
|
||||
theater: Music,
|
||||
entertainment: Music,
|
||||
shopping: Tag,
|
||||
religious: MapPin,
|
||||
viewpoint: Camera,
|
||||
hotel: Hotel,
|
||||
transport: Navigation,
|
||||
transfer: Navigation,
|
||||
airport: Plane,
|
||||
event: Ticket,
|
||||
};
|
||||
|
||||
function TimelineStepMarker({ index, refType, isLast, hasSegment }: {
|
||||
index: number;
|
||||
refType: string;
|
||||
isLast: boolean;
|
||||
hasSegment: boolean;
|
||||
}) {
|
||||
const Icon = stepIconMap[refType] || MapPin;
|
||||
const showLine = !isLast || hasSegment;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center flex-shrink-0">
|
||||
<div className="relative w-7 h-7 rounded-full bg-accent/15 border-2 border-accent/40 flex items-center justify-center group-hover:border-accent/60 transition-colors">
|
||||
<Icon className="w-3 h-3 text-accent" />
|
||||
<span className="absolute -top-1 -right-1 w-3.5 h-3.5 rounded-full bg-accent text-white text-[8px] font-bold flex items-center justify-center leading-none">
|
||||
{index}
|
||||
</span>
|
||||
</div>
|
||||
{showLine && (
|
||||
<div className="w-px flex-1 min-h-[16px] bg-gradient-to-b from-accent/30 to-border/20 mt-1" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Transport Segment Card ---
|
||||
|
||||
function TransportSegmentCard({ segment }: { segment: RouteSegment }) {
|
||||
@@ -553,33 +545,31 @@ function TransportSegmentCard({ segment }: { segment: RouteSegment }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-2 my-1 p-2 rounded-lg bg-accent/5 border border-accent/15">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Navigation className="w-3 h-3 text-accent" />
|
||||
<span className="text-[10px] text-muted uppercase tracking-wide">Как добраться</span>
|
||||
{distKm && (
|
||||
<span className="text-[10px] text-secondary">{distKm} км</span>
|
||||
)}
|
||||
{durationMin !== null && durationMin > 0 && (
|
||||
<span className="text-[10px] text-secondary">~{durationMin} мин</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{segment.transportOptions.map((opt) => {
|
||||
const Icon = modeIcons[opt.mode] || Navigation;
|
||||
return (
|
||||
<div
|
||||
key={opt.mode}
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-surface/50 border border-border/30"
|
||||
>
|
||||
<Icon className="w-3 h-3 text-muted" />
|
||||
<span className="text-[11px] text-secondary">{opt.label}</span>
|
||||
<span className="text-[11px] font-medium text-primary">
|
||||
~{opt.price.toLocaleString()} {opt.currency === 'RUB' ? '₽' : opt.currency}
|
||||
<div className="ml-3 pl-5 border-l border-dashed border-accent/20">
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-muted">
|
||||
<Navigation className="w-2.5 h-2.5 text-accent/60" />
|
||||
{distKm && <span>{distKm} км</span>}
|
||||
{distKm && durationMin !== null && durationMin > 0 && <span className="text-border">·</span>}
|
||||
{durationMin !== null && durationMin > 0 && <span>~{durationMin} мин</span>}
|
||||
</div>
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{segment.transportOptions.map((opt) => {
|
||||
const Icon = modeIcons[opt.mode] || Navigation;
|
||||
return (
|
||||
<span
|
||||
key={opt.mode}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-accent/8 text-[10px] text-secondary"
|
||||
>
|
||||
<Icon className="w-2.5 h-2.5 text-accent/50" />
|
||||
{opt.label}
|
||||
<span className="font-medium text-primary">
|
||||
~{opt.price.toLocaleString()} {opt.currency === 'RUB' ? '₽' : opt.currency}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -591,10 +581,16 @@ interface ItineraryWidgetProps {
|
||||
days: ItineraryDay[];
|
||||
budget?: BudgetBreakdown;
|
||||
segments?: RouteSegment[];
|
||||
dailyForecast?: DailyForecast[];
|
||||
pois?: POICard[];
|
||||
events?: EventCard[];
|
||||
onItineraryUpdate?: (days: ItineraryDay[]) => void;
|
||||
onValidateWithLLM?: (days: ItineraryDay[]) => Promise<import('@/lib/hooks/useEditableItinerary').LLMValidationResponse | null>;
|
||||
}
|
||||
|
||||
function ItineraryWidget({ days, segments }: ItineraryWidgetProps) {
|
||||
function ItineraryWidget({ days, segments, dailyForecast, pois, events, onItineraryUpdate, onValidateWithLLM }: ItineraryWidgetProps) {
|
||||
const [expandedDay, setExpandedDay] = useState<number>(0);
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
|
||||
const findSegment = useCallback((fromTitle: string, toTitle: string): RouteSegment | undefined => {
|
||||
if (!segments || segments.length === 0) return undefined;
|
||||
@@ -603,87 +599,142 @@ function ItineraryWidget({ days, segments }: ItineraryWidgetProps) {
|
||||
);
|
||||
}, [segments]);
|
||||
|
||||
const findWeather = useCallback((date: string): DailyForecast | undefined => {
|
||||
if (!dailyForecast || dailyForecast.length === 0) return undefined;
|
||||
return dailyForecast.find((d) => d.date === date);
|
||||
}, [dailyForecast]);
|
||||
|
||||
let globalStep = 0;
|
||||
|
||||
if (isEditMode && pois && events) {
|
||||
return (
|
||||
<EditableItinerary
|
||||
days={days}
|
||||
pois={pois}
|
||||
events={events}
|
||||
dailyForecast={dailyForecast}
|
||||
onApply={(editedDays) => {
|
||||
onItineraryUpdate?.(editedDays);
|
||||
setIsEditMode(false);
|
||||
}}
|
||||
onCancel={() => setIsEditMode(false)}
|
||||
onValidateWithLLM={onValidateWithLLM}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{days.map((day, idx) => (
|
||||
<motion.div
|
||||
key={day.date}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: idx * 0.05 }}
|
||||
className="border border-border/40 rounded-xl overflow-hidden"
|
||||
>
|
||||
<button
|
||||
onClick={() => setExpandedDay(expandedDay === idx ? -1 : idx)}
|
||||
className="w-full flex items-center justify-between p-3 bg-elevated/40 hover:bg-elevated/60 transition-colors"
|
||||
{days.map((day, idx) => {
|
||||
const dayStartStep = globalStep;
|
||||
globalStep += day.items.length;
|
||||
const dayWeather = findWeather(day.date) || (dailyForecast && dailyForecast[idx]);
|
||||
const WeatherDayIcon = dayWeather ? (weatherIconMap[dayWeather.icon] || CloudSun) : null;
|
||||
const weatherColor = dayWeather ? (weatherIconColors[dayWeather.icon] || 'text-sky-400') : '';
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={day.date}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: idx * 0.05 }}
|
||||
className="border border-border/30 rounded-xl overflow-hidden bg-elevated/20"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center">
|
||||
<span className="text-xs font-bold text-accent">Д{idx + 1}</span>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="text-sm font-medium text-primary">{day.date}</div>
|
||||
<div className="text-xs text-muted">{day.items.length} активностей</div>
|
||||
</div>
|
||||
</div>
|
||||
{expandedDay === idx ? (
|
||||
<ChevronUp className="w-4 h-4 text-muted" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4 text-muted" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{expandedDay === idx && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="p-3 space-y-0">
|
||||
{day.items.map((item, itemIdx) => {
|
||||
const nextItem = itemIdx < day.items.length - 1 ? day.items[itemIdx + 1] : null;
|
||||
const segment = nextItem ? findSegment(item.title, nextItem.title) : undefined;
|
||||
|
||||
return (
|
||||
<div key={`${item.refId}-${itemIdx}`}>
|
||||
<div className="flex items-start gap-3 p-2 rounded-lg bg-surface/30">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-2 h-2 rounded-full bg-accent" />
|
||||
{(itemIdx < day.items.length - 1 || segment) && (
|
||||
<div className="w-0.5 h-8 bg-border/40 mt-1" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{item.startTime && (
|
||||
<span className="text-xs text-accent font-mono">{item.startTime}</span>
|
||||
)}
|
||||
<span className="text-sm text-primary">{item.title}</span>
|
||||
</div>
|
||||
{item.note && (
|
||||
<p className="text-xs text-muted mt-0.5">{item.note}</p>
|
||||
)}
|
||||
{(item.cost ?? 0) > 0 && (
|
||||
<span className="text-xs text-secondary">
|
||||
~{item.cost} {item.currency || '₽'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{segment && segment.transportOptions && segment.transportOptions.length > 0 && (
|
||||
<TransportSegmentCard segment={segment} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={() => setExpandedDay(expandedDay === idx ? -1 : idx)}
|
||||
className="w-full flex items-center justify-between px-3.5 py-2.5 hover:bg-surface/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="w-6 h-6 rounded-md bg-accent text-white text-[10px] font-bold flex items-center justify-center leading-none">
|
||||
{idx + 1}
|
||||
</span>
|
||||
<div className="text-left">
|
||||
<span className="text-[13px] font-medium text-primary">{day.date}</span>
|
||||
<span className="text-[11px] text-muted ml-2">{day.items.length} мест</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{idx === 0 && (pois && pois.length > 0 || events && events.length > 0) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); setIsEditMode(true); }}
|
||||
className="w-7 h-7 inline-flex items-center justify-center text-muted hover:text-accent bg-surface/30 hover:bg-accent/10 rounded-lg transition-colors border border-border/30 hover:border-accent/30"
|
||||
title="Редактировать маршрут"
|
||||
aria-label="Редактировать маршрут"
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
{dayWeather && WeatherDayIcon && (
|
||||
<div className="flex items-center gap-1.5" title={dayWeather.conditions}>
|
||||
<WeatherDayIcon className={`w-3.5 h-3.5 ${weatherColor}`} />
|
||||
<span className="text-[10px] text-secondary">
|
||||
{formatTemp(dayWeather.tempMin)}..{formatTemp(dayWeather.tempMax)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<ChevronDown className={`w-4 h-4 text-muted transition-transform duration-200 ${expandedDay === idx ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{expandedDay === idx && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="px-3.5 pb-3 pt-1">
|
||||
{day.items.map((item, itemIdx) => {
|
||||
const nextItem = itemIdx < day.items.length - 1 ? day.items[itemIdx + 1] : null;
|
||||
const segment = nextItem ? findSegment(item.title, nextItem.title) : undefined;
|
||||
const stepNum = dayStartStep + itemIdx + 1;
|
||||
const isLast = itemIdx === day.items.length - 1;
|
||||
|
||||
return (
|
||||
<div key={`${item.refId}-${itemIdx}`} className="group">
|
||||
<div className="flex items-start gap-2.5 py-1.5">
|
||||
<TimelineStepMarker
|
||||
index={stepNum}
|
||||
refType={item.refType}
|
||||
isLast={isLast && !segment}
|
||||
hasSegment={!!segment}
|
||||
/>
|
||||
<div className="flex-1 min-w-0 pt-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{item.startTime && (
|
||||
<span className="text-[11px] text-accent font-mono bg-accent/8 px-1.5 py-px rounded">
|
||||
{item.startTime}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[13px] font-medium text-primary truncate">{item.title}</span>
|
||||
</div>
|
||||
{item.note && (
|
||||
<p className="text-[11px] text-muted mt-0.5 line-clamp-2 leading-relaxed">{item.note}</p>
|
||||
)}
|
||||
{(item.cost ?? 0) > 0 && (
|
||||
<span className="inline-flex items-center gap-0.5 text-[10px] text-accent/80 mt-0.5">
|
||||
<DollarSign className="w-2.5 h-2.5" />
|
||||
~{item.cost?.toLocaleString('ru-RU')} {item.currency || '₽'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{segment && segment.transportOptions && segment.transportOptions.length > 0 && (
|
||||
<TransportSegmentCard segment={segment} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -762,9 +813,10 @@ function BudgetWidget({ breakdown }: BudgetWidgetProps) {
|
||||
interface ClarifyingQuestionsWidgetProps {
|
||||
questions: ClarifyingQuestion[];
|
||||
onAnswer: (field: string, value: string) => void;
|
||||
onSubmittedNotice?: (text: string) => void;
|
||||
}
|
||||
|
||||
function ClarifyingQuestionsWidget({ questions, onAnswer }: ClarifyingQuestionsWidgetProps) {
|
||||
function ClarifyingQuestionsWidget({ questions, onAnswer, onSubmittedNotice }: ClarifyingQuestionsWidgetProps) {
|
||||
const [answers, setAnswers] = useState<Record<string, string>>({});
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
@@ -779,18 +831,20 @@ function ClarifyingQuestionsWidget({ questions, onAnswer }: ClarifyingQuestionsW
|
||||
setSubmitted(true);
|
||||
const combined = filled.map(([field, value]) => `${field}: ${value}`).join('\n');
|
||||
onAnswer('_batch', combined);
|
||||
}, [answers, onAnswer]);
|
||||
onSubmittedNotice?.('Детали отправлены, планирую маршрут…');
|
||||
}, [answers, onAnswer, onSubmittedNotice]);
|
||||
|
||||
if (submitted) {
|
||||
if (onSubmittedNotice) return null;
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="p-4 rounded-xl border border-green-500/30 bg-green-500/5"
|
||||
className="px-3 py-2 rounded-lg border border-green-500/25 bg-green-500/5"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="w-4 h-4 text-green-600" />
|
||||
<span className="text-sm text-green-600">Детали отправлены, планирую маршрут...</span>
|
||||
<Check className="w-3.5 h-3.5 text-green-600" />
|
||||
<span className="text-[12px] leading-4 text-green-600">Детали отправлены, планирую маршрут…</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
@@ -851,6 +905,119 @@ function ClarifyingQuestionsWidget({ questions, onAnswer }: ClarifyingQuestionsW
|
||||
);
|
||||
}
|
||||
|
||||
function TipsCollapsible({ tips, tipIcons }: { tips: TravelTip[]; tipIcons: Record<string, typeof Info> }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-2 px-1 w-full text-left"
|
||||
>
|
||||
<Lightbulb className="w-3.5 h-3.5 text-accent" />
|
||||
<span className="text-xs font-medium text-primary">Советы</span>
|
||||
<span className="px-1.5 py-0.5 text-[10px] bg-accent/20 text-accent rounded-full">{tips.length}</span>
|
||||
<span className="ml-auto">
|
||||
{open ? <ChevronUp className="w-3.5 h-3.5 text-muted" /> : <ChevronDown className="w-3.5 h-3.5 text-muted" />}
|
||||
</span>
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="space-y-2 mt-2">
|
||||
{tips.map((tip, i) => {
|
||||
const TipIcon = tipIcons[tip.category] || Info;
|
||||
return (
|
||||
<div key={i} className="flex items-start gap-2 p-3 rounded-lg bg-elevated/30 border border-border/20">
|
||||
<TipIcon className="w-3.5 h-3.5 text-accent flex-shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-secondary">{tip.text}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Weather Icon Mapping ---
|
||||
|
||||
const weatherIconMap: Record<WeatherIcon | string, typeof Sun> = {
|
||||
sun: Sun,
|
||||
cloud: Cloud,
|
||||
'cloud-sun': CloudSun,
|
||||
rain: CloudRain,
|
||||
storm: CloudLightning,
|
||||
snow: Snowflake,
|
||||
fog: CloudFog,
|
||||
wind: Wind,
|
||||
};
|
||||
|
||||
const weatherIconColors: Record<WeatherIcon | string, string> = {
|
||||
sun: 'text-amber-400',
|
||||
cloud: 'text-slate-400',
|
||||
'cloud-sun': 'text-sky-400',
|
||||
rain: 'text-blue-400',
|
||||
storm: 'text-purple-400',
|
||||
snow: 'text-cyan-300',
|
||||
fog: 'text-slate-300',
|
||||
wind: 'text-teal-400',
|
||||
};
|
||||
|
||||
function formatTemp(temp: number): string {
|
||||
return `${temp > 0 ? '+' : ''}${Math.round(temp)}°`;
|
||||
}
|
||||
|
||||
function formatDateShort(dateStr: string): string {
|
||||
try {
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
const weekdays = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
|
||||
const day = d.getDate();
|
||||
const months = ['янв', 'фев', 'мар', 'апр', 'мая', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек'];
|
||||
return `${weekdays[d.getDay()]}, ${day} ${months[d.getMonth()]}`;
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
function DailyForecastRow({ day }: { day: DailyForecast }) {
|
||||
const Icon = weatherIconMap[day.icon] || CloudSun;
|
||||
const iconColor = weatherIconColors[day.icon] || 'text-sky-400';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-1.5 px-2 rounded-lg hover:bg-surface/30 transition-colors group">
|
||||
<span className="text-[10px] text-muted w-[70px] flex-shrink-0 leading-tight">
|
||||
{formatDateShort(day.date)}
|
||||
</span>
|
||||
<Icon className={`w-4 h-4 ${iconColor} flex-shrink-0`} />
|
||||
<span className="text-[11px] font-medium text-primary w-[52px] text-right flex-shrink-0">
|
||||
{formatTemp(day.tempMin)}..{formatTemp(day.tempMax)}
|
||||
</span>
|
||||
<span className="text-[10px] text-secondary truncate flex-1">
|
||||
{day.conditions}
|
||||
</span>
|
||||
{day.rainChance && day.rainChance !== 'низкая' && (
|
||||
<span className="flex items-center gap-0.5 flex-shrink-0">
|
||||
<Droplets className="w-2.5 h-2.5 text-blue-400" />
|
||||
<span className="text-[9px] text-blue-400">{day.rainChance}</span>
|
||||
</span>
|
||||
)}
|
||||
{day.tip && (
|
||||
<span className="hidden group-hover:block absolute right-0 top-full z-10 p-2 bg-elevated border border-border/40 rounded-lg text-[10px] text-secondary max-w-[200px] shadow-lg">
|
||||
{day.tip}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Travel Context Widget ---
|
||||
|
||||
interface TravelContextWidgetProps {
|
||||
@@ -862,6 +1029,8 @@ interface TravelContextWidgetProps {
|
||||
}
|
||||
|
||||
function TravelContextWidget({ weather, safety, restrictions, tips, bestTimeInfo }: TravelContextWidgetProps) {
|
||||
const [showAllDays, setShowAllDays] = useState(false);
|
||||
|
||||
const safetyColors: Record<string, { bg: string; text: string; icon: typeof ShieldCheck }> = {
|
||||
safe: { bg: 'bg-green-500/10', text: 'text-green-400', icon: ShieldCheck },
|
||||
caution: { bg: 'bg-yellow-500/10', text: 'text-yellow-400', icon: Shield },
|
||||
@@ -893,29 +1062,68 @@ function TravelContextWidget({ weather, safety, restrictions, tips, bestTimeInfo
|
||||
const safetyStyle = safetyColors[safety.level] || safetyColors.safe;
|
||||
const SafetyIcon = safetyStyle.icon;
|
||||
|
||||
const dailyForecast = weather.dailyForecast || [];
|
||||
const visibleDays = showAllDays ? dailyForecast : dailyForecast.slice(0, 4);
|
||||
const hasMoreDays = dailyForecast.length > 4;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Weather */}
|
||||
<div className="p-4 rounded-xl border border-border/40 bg-elevated/40">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<CloudSun className="w-4 h-4 text-sky-400" />
|
||||
<h4 className="text-sm font-medium text-primary">Погода</h4>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Thermometer className="w-3.5 h-3.5 text-orange-400" />
|
||||
<span className="text-xs text-secondary">
|
||||
{weather.tempMin > 0 ? '+' : ''}{weather.tempMin}° ... {weather.tempMax > 0 ? '+' : ''}{weather.tempMax}°C
|
||||
<CloudSun className="w-4 h-4 text-sky-400" />
|
||||
<h4 className="text-sm font-medium text-primary">Погода</h4>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex items-center gap-1 text-xs text-secondary">
|
||||
<Thermometer className="w-3 h-3 text-orange-400" />
|
||||
{formatTemp(weather.tempMin)}..{formatTemp(weather.tempMax)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-xs text-secondary">
|
||||
<Umbrella className="w-3 h-3 text-blue-400" />
|
||||
{weather.rainChance}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Umbrella className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span className="text-xs text-secondary">Осадки: {weather.rainChance}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-secondary mt-2">{weather.summary}</p>
|
||||
|
||||
<p className="text-xs text-secondary">{weather.summary}</p>
|
||||
|
||||
{weather.clothing && (
|
||||
<p className="text-xs text-muted mt-1.5 italic">{weather.clothing}</p>
|
||||
<p className="text-[11px] text-muted mt-1.5 italic">{weather.clothing}</p>
|
||||
)}
|
||||
|
||||
{/* Daily forecast */}
|
||||
{dailyForecast.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-border/30">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-[10px] text-muted uppercase tracking-wide">Прогноз по дням</span>
|
||||
<span className="text-[10px] text-muted">{dailyForecast.length} дн.</span>
|
||||
</div>
|
||||
<div className="space-y-0">
|
||||
{visibleDays.map((day) => (
|
||||
<DailyForecastRow key={day.date} day={day} />
|
||||
))}
|
||||
</div>
|
||||
{hasMoreDays && (
|
||||
<button
|
||||
onClick={() => setShowAllDays(!showAllDays)}
|
||||
className="flex items-center gap-1 mt-1 px-2 py-1 text-[10px] text-accent hover:text-accent-hover transition-colors"
|
||||
>
|
||||
{showAllDays ? (
|
||||
<>
|
||||
<ChevronUp className="w-3 h-3" />
|
||||
Свернуть
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
Ещё {dailyForecast.length - 4} дн.
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -965,23 +1173,9 @@ function TravelContextWidget({ weather, safety, restrictions, tips, bestTimeInfo
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tips */}
|
||||
{/* Tips (collapsed by default) */}
|
||||
{tips.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<Lightbulb className="w-3.5 h-3.5 text-accent" />
|
||||
<span className="text-xs font-medium text-primary">Советы</span>
|
||||
</div>
|
||||
{tips.map((tip, i) => {
|
||||
const TipIcon = tipIcons[tip.category] || Info;
|
||||
return (
|
||||
<div key={i} className="flex items-start gap-2 p-3 rounded-lg bg-elevated/30 border border-border/20">
|
||||
<TipIcon className="w-3.5 h-3.5 text-accent flex-shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-secondary">{tip.text}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<TipsCollapsible tips={tips} tipIcons={tipIcons} />
|
||||
)}
|
||||
|
||||
{/* Best time info */}
|
||||
@@ -1008,10 +1202,15 @@ interface TravelWidgetRendererProps {
|
||||
onSelectTransport?: (option: TransportOption) => void;
|
||||
onClarifyingAnswer?: (field: string, value: string) => void;
|
||||
onAction?: (kind: string) => void;
|
||||
onInlineNotice?: (text: string) => void;
|
||||
selectedEventIds?: Set<string>;
|
||||
selectedPOIIds?: Set<string>;
|
||||
selectedHotelId?: string;
|
||||
selectedTransportId?: string;
|
||||
availablePois?: POICard[];
|
||||
availableEvents?: EventCard[];
|
||||
onItineraryUpdate?: (days: ItineraryDay[]) => void;
|
||||
onValidateItineraryWithLLM?: (days: ItineraryDay[]) => Promise<import('@/lib/hooks/useEditableItinerary').LLMValidationResponse | null>;
|
||||
}
|
||||
|
||||
export function TravelWidgetRenderer({
|
||||
@@ -1022,10 +1221,15 @@ export function TravelWidgetRenderer({
|
||||
onSelectTransport,
|
||||
onClarifyingAnswer,
|
||||
onAction,
|
||||
onInlineNotice,
|
||||
selectedEventIds = new Set(),
|
||||
selectedPOIIds = new Set(),
|
||||
selectedHotelId,
|
||||
selectedTransportId,
|
||||
availablePois,
|
||||
availableEvents,
|
||||
onItineraryUpdate,
|
||||
onValidateItineraryWithLLM,
|
||||
}: TravelWidgetRendererProps) {
|
||||
switch (widget.type) {
|
||||
case 'travel_context': {
|
||||
@@ -1130,10 +1334,20 @@ export function TravelWidgetRenderer({
|
||||
const days = (widget.params.days || []) as ItineraryDay[];
|
||||
const budgetData = widget.params.budget as BudgetBreakdown | undefined;
|
||||
const segmentsData = (widget.params.segments || []) as RouteSegment[];
|
||||
const forecastData = (widget.params.dailyForecast || []) as DailyForecast[];
|
||||
if (days.length === 0) return null;
|
||||
return (
|
||||
<WidgetSection title="Маршрут по дням" icon={<Calendar className="w-4 h-4" />} count={days.length}>
|
||||
<ItineraryWidget days={days} budget={budgetData} segments={segmentsData} />
|
||||
<ItineraryWidget
|
||||
days={days}
|
||||
budget={budgetData}
|
||||
segments={segmentsData}
|
||||
dailyForecast={forecastData}
|
||||
pois={availablePois}
|
||||
events={availableEvents}
|
||||
onItineraryUpdate={onItineraryUpdate}
|
||||
onValidateWithLLM={onValidateItineraryWithLLM}
|
||||
/>
|
||||
</WidgetSection>
|
||||
);
|
||||
}
|
||||
@@ -1151,6 +1365,7 @@ export function TravelWidgetRenderer({
|
||||
<ClarifyingQuestionsWidget
|
||||
questions={questions}
|
||||
onAnswer={onClarifyingAnswer || (() => {})}
|
||||
onSubmittedNotice={onInlineNotice}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,9 +30,13 @@ import type {
|
||||
RoutePoint,
|
||||
TravelSuggestion,
|
||||
TravelPlanRequest,
|
||||
LearningCourse,
|
||||
LearningUserProfile,
|
||||
LearningEnrollment,
|
||||
LearningTask,
|
||||
} from './types';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || process.env.API_URL || '';
|
||||
|
||||
function getAuthHeaders(): HeadersInit {
|
||||
const headers: HeadersInit = {
|
||||
@@ -47,6 +51,17 @@ function getAuthHeaders(): HeadersInit {
|
||||
return headers;
|
||||
}
|
||||
|
||||
function getAuthHeadersWithoutContentType(): HeadersInit {
|
||||
const headers: HeadersInit = {};
|
||||
if (typeof window !== 'undefined') {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
export async function* streamChat(request: ChatRequest): AsyncGenerator<StreamEvent> {
|
||||
const response = await fetch(`${API_BASE}/api/chat`, {
|
||||
method: 'POST',
|
||||
@@ -497,6 +512,197 @@ export async function deleteLesson(id: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// --- New Learning Cabinet API ---
|
||||
|
||||
export async function fetchLearningCourses(params?: {
|
||||
category?: string;
|
||||
difficulty?: string;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<{ courses: LearningCourse[]; total: number }> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.category) searchParams.set('category', params.category);
|
||||
if (params?.difficulty) searchParams.set('difficulty', params.difficulty);
|
||||
if (params?.search) searchParams.set('search', params.search);
|
||||
searchParams.set('limit', String(params?.limit ?? 20));
|
||||
searchParams.set('offset', String(params?.offset ?? 0));
|
||||
|
||||
const response = await fetch(`${API_BASE}/api/v1/learning/courses?${searchParams}`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Courses fetch failed: ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchLearningCourse(slug: string): Promise<LearningCourse | null> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/learning/courses/${slug}`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) return null;
|
||||
throw new Error(`Course fetch failed: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchLearningProfile(): Promise<{ profile: LearningUserProfile | null; exists: boolean }> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/learning/me/profile`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) return { profile: null, exists: false };
|
||||
throw new Error(`Profile fetch failed: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function saveLearningProfile(data: {
|
||||
displayName?: string;
|
||||
profile?: Record<string, unknown>;
|
||||
onboardingCompleted?: boolean;
|
||||
}): Promise<{ success: boolean }> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/learning/me/profile`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Profile save failed: ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function submitLearningOnboarding(data: {
|
||||
displayName?: string;
|
||||
answers: Record<string, string>;
|
||||
}): Promise<{ success: boolean; profile: Record<string, unknown> }> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/learning/me/onboarding`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Onboarding submit failed: ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function submitResume(fileId: string, extractedText: string): Promise<{ success: boolean; profile: Record<string, unknown> }> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/learning/me/resume`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ fileId, extractedText }),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Resume submit failed: ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export interface UploadedUserFile {
|
||||
id: string;
|
||||
filename: string;
|
||||
fileType: string;
|
||||
fileSize: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface UploadedFileContent {
|
||||
id: string;
|
||||
filename: string;
|
||||
extractedText: string;
|
||||
}
|
||||
|
||||
export async function uploadUserFile(file: File): Promise<UploadedUserFile> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch(`${API_BASE}/api/v1/files/upload`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeadersWithoutContentType(),
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`File upload failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchUploadedFileContent(fileId: string): Promise<UploadedFileContent> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/files/${fileId}/content`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`File content fetch failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function analyzeUploadedFile(fileId: string): Promise<{ extractedText?: string }> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/files/${fileId}/analyze`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`File analyze failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function createSandboxSession(payload?: { taskId?: string; image?: string }): Promise<{ id: string; status: string }> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/sandbox/sessions`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
taskId: payload?.taskId,
|
||||
image: payload?.image,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Sandbox create failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function enrollInCourse(courseId: string): Promise<LearningEnrollment> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/learning/enroll`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ courseId }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 409) throw new Error('Already enrolled');
|
||||
throw new Error(`Enroll failed: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchEnrollments(): Promise<{ enrollments: LearningEnrollment[] }> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/learning/enrollments`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) return { enrollments: [] };
|
||||
throw new Error(`Enrollments fetch failed: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchEnrollment(id: string): Promise<{ enrollment: LearningEnrollment; tasks: LearningTask[] } | null> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/learning/enrollments/${id}`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) return null;
|
||||
throw new Error(`Enrollment fetch failed: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchAdminDashboard(): Promise<DashboardStats> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/admin/dashboard`, {
|
||||
headers: getAuthHeaders(),
|
||||
@@ -1091,6 +1297,7 @@ export async function* streamTravelAgent(
|
||||
userLocation?: { lat: number; lng: number; name?: string };
|
||||
},
|
||||
chatId?: string,
|
||||
signal?: AbortSignal,
|
||||
): AsyncGenerator<StreamEvent> {
|
||||
const chatHistory = history.flatMap(([user, assistant]) => [
|
||||
['human', user],
|
||||
@@ -1132,6 +1339,7 @@ export async function* streamTravelAgent(
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(request),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -1146,32 +1354,102 @@ export async function* streamTravelAgent(
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
try {
|
||||
while (true) {
|
||||
if (signal?.aborted) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const event = JSON.parse(line) as StreamEvent;
|
||||
yield event;
|
||||
} catch {
|
||||
// skip malformed NDJSON lines
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.trim() && !signal?.aborted) {
|
||||
try {
|
||||
const event = JSON.parse(buffer) as StreamEvent;
|
||||
yield event;
|
||||
} catch {
|
||||
// skip malformed final buffer
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
export async function* streamMedicineConsult(
|
||||
symptoms: string,
|
||||
city?: string,
|
||||
history?: [string, string][],
|
||||
signal?: AbortSignal
|
||||
): AsyncGenerator<StreamEvent> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/medicine/consult`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
signal,
|
||||
body: JSON.stringify({
|
||||
symptoms,
|
||||
city,
|
||||
history,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Medicine consult request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error('No response body');
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
if (signal?.aborted) break;
|
||||
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const event = JSON.parse(line) as StreamEvent;
|
||||
yield event;
|
||||
} catch {
|
||||
console.warn('Failed to parse travel agent event:', line);
|
||||
// skip malformed NDJSON lines
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.trim()) {
|
||||
try {
|
||||
const event = JSON.parse(buffer) as StreamEvent;
|
||||
yield event;
|
||||
} catch {
|
||||
console.warn('Failed to parse final travel agent buffer:', buffer);
|
||||
if (buffer.trim() && !signal?.aborted) {
|
||||
try {
|
||||
const event = JSON.parse(buffer) as StreamEvent;
|
||||
yield event;
|
||||
} catch {
|
||||
// skip malformed final buffer
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
/**
|
||||
* Конфигурация видимости пунктов меню
|
||||
*
|
||||
* Управляется через переменные окружения:
|
||||
* - NEXT_PUBLIC_DISABLED_ROUTES — список отключённых маршрутов через запятую
|
||||
*
|
||||
* Пример: NEXT_PUBLIC_DISABLED_ROUTES=/travel,/medicine,/finance,/learning
|
||||
*
|
||||
* NEXT_PUBLIC_ENABLED_ROUTES — список включённых маршрутов через запятую.
|
||||
* Если задан — показываются ТОЛЬКО перечисленные маршруты (+ корень "/" всегда виден).
|
||||
* Если пуст или не задан — показываются все маршруты.
|
||||
*
|
||||
* Пример: NEXT_PUBLIC_ENABLED_ROUTES=/travel,/medicine,/discover
|
||||
*/
|
||||
|
||||
type RouteId =
|
||||
type RouteId =
|
||||
| '/'
|
||||
| '/discover'
|
||||
| '/spaces'
|
||||
@@ -18,39 +19,45 @@ type RouteId =
|
||||
| '/learning'
|
||||
| '/settings';
|
||||
|
||||
const parseDisabledRoutes = (): Set<string> => {
|
||||
const envValue = process.env.NEXT_PUBLIC_DISABLED_ROUTES || '';
|
||||
const ALWAYS_VISIBLE: ReadonlySet<string> = new Set(['/', '/settings']);
|
||||
|
||||
const parseEnabledRoutes = (): Set<string> | null => {
|
||||
const envValue = process.env.NEXT_PUBLIC_ENABLED_ROUTES || '';
|
||||
if (!envValue.trim()) {
|
||||
return new Set();
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
const routes = envValue
|
||||
.split(',')
|
||||
.map(route => route.trim())
|
||||
.filter(route => route.startsWith('/'));
|
||||
|
||||
.map((route) => route.trim())
|
||||
.filter((route) => route.startsWith('/'));
|
||||
|
||||
return new Set(routes);
|
||||
};
|
||||
|
||||
const disabledRoutes = parseDisabledRoutes();
|
||||
const enabledRoutes = parseEnabledRoutes();
|
||||
|
||||
/**
|
||||
* Проверяет, включён ли маршрут для отображения
|
||||
* Проверяет, включён ли маршрут для отображения.
|
||||
* Если NEXT_PUBLIC_ENABLED_ROUTES задан — показываем только то, что в списке + ALWAYS_VISIBLE.
|
||||
* Если не задан — показываем всё.
|
||||
*/
|
||||
export function isRouteEnabled(route: string): boolean {
|
||||
return !disabledRoutes.has(route);
|
||||
if (ALWAYS_VISIBLE.has(route)) return true;
|
||||
if (enabledRoutes === null) return true;
|
||||
return enabledRoutes.has(route);
|
||||
}
|
||||
|
||||
/**
|
||||
* Фильтрует массив пунктов меню по включённым маршрутам
|
||||
*/
|
||||
export function filterMenuItems<T extends { href: string }>(items: T[]): T[] {
|
||||
return items.filter(item => isRouteEnabled(item.href));
|
||||
return items.filter((item) => isRouteEnabled(item.href));
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает список отключённых маршрутов
|
||||
* Получает список включённых маршрутов (или null если все включены)
|
||||
*/
|
||||
export function getDisabledRoutes(): string[] {
|
||||
return Array.from(disabledRoutes);
|
||||
export function getEnabledRoutes(): string[] | null {
|
||||
return enabledRoutes ? Array.from(enabledRoutes) : null;
|
||||
}
|
||||
|
||||
94
backend/webui/src/lib/contexts/ThemeContext.tsx
Normal file
94
backend/webui/src/lib/contexts/ThemeContext.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
export type ThemeMode = 'light' | 'dim';
|
||||
|
||||
const THEME_STORAGE_KEY = 'gooseek_theme';
|
||||
|
||||
function readStoredTheme(): ThemeMode | null {
|
||||
try {
|
||||
const v = localStorage.getItem(THEME_STORAGE_KEY);
|
||||
if (v === 'light' || v === 'dim') return v;
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function prefersDark(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return !!window.matchMedia?.('(prefers-color-scheme: dark)')?.matches;
|
||||
}
|
||||
|
||||
function applyTheme(theme: ThemeMode): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
const root = document.documentElement;
|
||||
if (theme === 'dim') root.classList.add('theme-dim');
|
||||
else root.classList.remove('theme-dim');
|
||||
try {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, theme);
|
||||
} catch {
|
||||
// ignore storage failures (private mode, quota, etc.)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveInitialTheme(): ThemeMode {
|
||||
if (typeof document !== 'undefined' && document.documentElement.classList.contains('theme-dim')) {
|
||||
return 'dim';
|
||||
}
|
||||
const stored = typeof window !== 'undefined' ? readStoredTheme() : null;
|
||||
if (stored) return stored;
|
||||
return prefersDark() ? 'dim' : 'light';
|
||||
}
|
||||
|
||||
type ThemeContextValue = {
|
||||
theme: ThemeMode;
|
||||
setTheme: (theme: ThemeMode) => void;
|
||||
toggleTheme: () => void;
|
||||
};
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setThemeState] = useState<ThemeMode>('light');
|
||||
|
||||
useEffect(() => {
|
||||
const initial = resolveInitialTheme();
|
||||
setThemeState(initial);
|
||||
applyTheme(initial);
|
||||
}, []);
|
||||
|
||||
const setTheme = useCallback((next: ThemeMode) => {
|
||||
setThemeState(next);
|
||||
applyTheme(next);
|
||||
}, []);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setTheme(theme === 'light' ? 'dim' : 'light');
|
||||
}, [theme, setTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key !== THEME_STORAGE_KEY) return;
|
||||
const v = e.newValue;
|
||||
if (v !== 'light' && v !== 'dim') return;
|
||||
setThemeState(v);
|
||||
applyTheme(v);
|
||||
};
|
||||
window.addEventListener('storage', onStorage);
|
||||
return () => window.removeEventListener('storage', onStorage);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({ theme, setTheme, toggleTheme }), [theme, setTheme, toggleTheme]);
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useTheme must be used within ThemeProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
375
backend/webui/src/lib/hooks/useEditableItinerary.ts
Normal file
375
backend/webui/src/lib/hooks/useEditableItinerary.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useMemo, useRef } from 'react';
|
||||
import type { ItineraryDay, ItineraryItem, POICard, EventCard, DailyForecast } from '../types';
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
reason?: string;
|
||||
severity: 'block' | 'warn';
|
||||
}
|
||||
|
||||
export interface EditableDay {
|
||||
date: string;
|
||||
items: ItineraryItem[];
|
||||
}
|
||||
|
||||
export type CustomItemType = 'food' | 'walk' | 'rest' | 'shopping' | 'custom';
|
||||
|
||||
interface UseEditableItineraryParams {
|
||||
initialDays: ItineraryDay[];
|
||||
poisMap: Map<string, POICard>;
|
||||
eventsMap: Map<string, EventCard>;
|
||||
dailyForecast?: DailyForecast[];
|
||||
onValidateWithLLM?: (days: EditableDay[]) => Promise<LLMValidationResponse | null>;
|
||||
}
|
||||
|
||||
export interface LLMValidationWarning {
|
||||
dayIdx: number;
|
||||
itemIdx?: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface LLMValidationResponse {
|
||||
valid: boolean;
|
||||
warnings: LLMValidationWarning[];
|
||||
suggestions: LLMValidationWarning[];
|
||||
}
|
||||
|
||||
const WEEKDAY_MAP: Record<string, number> = {
|
||||
'Пн': 1, 'Вт': 2, 'Ср': 3, 'Чт': 4, 'Пт': 5, 'Сб': 6, 'Вс': 0,
|
||||
'пн': 1, 'вт': 2, 'ср': 3, 'чт': 4, 'пт': 5, 'сб': 6, 'вс': 0,
|
||||
};
|
||||
|
||||
function getWeekdayName(date: Date): string {
|
||||
const names = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
|
||||
return names[date.getDay()];
|
||||
}
|
||||
|
||||
function parseTimeToMinutes(time: string): number {
|
||||
const parts = time.split(':');
|
||||
if (parts.length !== 2) return -1;
|
||||
const h = parseInt(parts[0], 10);
|
||||
const m = parseInt(parts[1], 10);
|
||||
if (isNaN(h) || isNaN(m)) return -1;
|
||||
return h * 60 + m;
|
||||
}
|
||||
|
||||
function isScheduleClosed(scheduleValue: string): boolean {
|
||||
const lower = scheduleValue.toLowerCase();
|
||||
return lower === 'выходной' || lower === 'закрыто' || lower === 'closed' || lower === '-';
|
||||
}
|
||||
|
||||
export function validateItemPlacement(
|
||||
item: ItineraryItem,
|
||||
targetDate: string,
|
||||
targetDayItems: ItineraryItem[],
|
||||
poisMap: Map<string, POICard>,
|
||||
eventsMap: Map<string, EventCard>,
|
||||
insertIndex?: number,
|
||||
): ValidationResult {
|
||||
const targetDateObj = new Date(targetDate + 'T00:00:00');
|
||||
if (isNaN(targetDateObj.getTime())) {
|
||||
return { valid: true, severity: 'warn' };
|
||||
}
|
||||
|
||||
if (item.refType === 'poi' && item.refId) {
|
||||
const poi = poisMap.get(item.refId);
|
||||
if (poi?.schedule && Object.keys(poi.schedule).length > 0) {
|
||||
const weekday = getWeekdayName(targetDateObj);
|
||||
const scheduleEntry = poi.schedule[weekday];
|
||||
if (scheduleEntry && isScheduleClosed(scheduleEntry)) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: `${item.title} закрыт в ${weekday} (${targetDate})`,
|
||||
severity: 'block',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (item.refType === 'event' && item.refId) {
|
||||
const event = eventsMap.get(item.refId);
|
||||
if (event) {
|
||||
if (event.dateStart) {
|
||||
const eventStart = new Date(event.dateStart + 'T00:00:00');
|
||||
const eventEnd = event.dateEnd
|
||||
? new Date(event.dateEnd + 'T00:00:00')
|
||||
: eventStart;
|
||||
if (targetDateObj < eventStart || targetDateObj > eventEnd) {
|
||||
const range = event.dateEnd
|
||||
? `${event.dateStart} — ${event.dateEnd}`
|
||||
: event.dateStart;
|
||||
return {
|
||||
valid: false,
|
||||
reason: `${item.title} проходит ${range}, нельзя поставить на ${targetDate}`,
|
||||
severity: 'block',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isDuplicate = targetDayItems.some(
|
||||
(existing, idx) =>
|
||||
existing.refId === item.refId &&
|
||||
existing.refId !== '' &&
|
||||
idx !== insertIndex,
|
||||
);
|
||||
if (isDuplicate) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: `${item.title} уже есть в этом дне`,
|
||||
severity: 'block',
|
||||
};
|
||||
}
|
||||
|
||||
if (item.refType !== 'food' && item.refType !== 'walk' && item.refType !== 'rest' &&
|
||||
item.refType !== 'shopping' && item.refType !== 'custom') {
|
||||
if ((!item.lat || item.lat === 0) && (!item.lng || item.lng === 0)) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: `${item.title} не имеет координат — не будет отображён на карте`,
|
||||
severity: 'warn',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (item.startTime && item.endTime) {
|
||||
const itemStart = parseTimeToMinutes(item.startTime);
|
||||
const itemEnd = parseTimeToMinutes(item.endTime);
|
||||
if (itemStart >= 0 && itemEnd >= 0) {
|
||||
for (let i = 0; i < targetDayItems.length; i++) {
|
||||
if (i === insertIndex) continue;
|
||||
const other = targetDayItems[i];
|
||||
if (!other.startTime || !other.endTime) continue;
|
||||
const otherStart = parseTimeToMinutes(other.startTime);
|
||||
const otherEnd = parseTimeToMinutes(other.endTime);
|
||||
if (otherStart < 0 || otherEnd < 0) continue;
|
||||
if (itemStart < otherEnd && itemEnd > otherStart) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: `Конфликт времени: ${item.title} (${item.startTime}-${item.endTime}) пересекается с ${other.title} (${other.startTime}-${other.endTime})`,
|
||||
severity: 'block',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true, severity: 'warn' };
|
||||
}
|
||||
|
||||
export function useEditableItinerary({
|
||||
initialDays,
|
||||
poisMap,
|
||||
eventsMap,
|
||||
onValidateWithLLM,
|
||||
}: UseEditableItineraryParams) {
|
||||
const [editableDays, setEditableDays] = useState<EditableDay[]>([]);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [llmWarnings, setLlmWarnings] = useState<LLMValidationWarning[]>([]);
|
||||
const [llmSuggestions, setLlmSuggestions] = useState<LLMValidationWarning[]>([]);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const originalDaysRef = useRef<ItineraryDay[]>([]);
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (!isEditing) return false;
|
||||
return JSON.stringify(editableDays) !== JSON.stringify(originalDaysRef.current);
|
||||
}, [editableDays, isEditing]);
|
||||
|
||||
const startEditing = useCallback(() => {
|
||||
const copy: EditableDay[] = initialDays.map((d) => ({
|
||||
date: d.date,
|
||||
items: d.items.map((item) => ({ ...item })),
|
||||
}));
|
||||
originalDaysRef.current = initialDays;
|
||||
setEditableDays(copy);
|
||||
setIsEditing(true);
|
||||
setLlmWarnings([]);
|
||||
setLlmSuggestions([]);
|
||||
}, [initialDays]);
|
||||
|
||||
const stopEditing = useCallback(() => {
|
||||
setIsEditing(false);
|
||||
setEditableDays([]);
|
||||
setLlmWarnings([]);
|
||||
setLlmSuggestions([]);
|
||||
}, []);
|
||||
|
||||
const resetChanges = useCallback(() => {
|
||||
const copy: EditableDay[] = originalDaysRef.current.map((d) => ({
|
||||
date: d.date,
|
||||
items: d.items.map((item) => ({ ...item })),
|
||||
}));
|
||||
setEditableDays(copy);
|
||||
setLlmWarnings([]);
|
||||
setLlmSuggestions([]);
|
||||
}, []);
|
||||
|
||||
const moveItem = useCallback(
|
||||
(
|
||||
fromDayIdx: number,
|
||||
fromItemIdx: number,
|
||||
toDayIdx: number,
|
||||
toItemIdx: number,
|
||||
): ValidationResult => {
|
||||
const days = editableDays;
|
||||
if (
|
||||
fromDayIdx < 0 || fromDayIdx >= days.length ||
|
||||
toDayIdx < 0 || toDayIdx >= days.length
|
||||
) {
|
||||
return { valid: false, reason: 'Неверный индекс дня', severity: 'block' };
|
||||
}
|
||||
|
||||
const item = days[fromDayIdx].items[fromItemIdx];
|
||||
if (!item) {
|
||||
return { valid: false, reason: 'Элемент не найден', severity: 'block' };
|
||||
}
|
||||
|
||||
const targetItems = fromDayIdx === toDayIdx
|
||||
? days[toDayIdx].items.filter((_, i) => i !== fromItemIdx)
|
||||
: [...days[toDayIdx].items];
|
||||
|
||||
const validation = validateItemPlacement(
|
||||
item,
|
||||
days[toDayIdx].date,
|
||||
targetItems,
|
||||
poisMap,
|
||||
eventsMap,
|
||||
);
|
||||
|
||||
if (!validation.valid) {
|
||||
return validation;
|
||||
}
|
||||
|
||||
setEditableDays((prev) => {
|
||||
const next = prev.map((d) => ({
|
||||
date: d.date,
|
||||
items: [...d.items],
|
||||
}));
|
||||
|
||||
const [movedItem] = next[fromDayIdx].items.splice(fromItemIdx, 1);
|
||||
const adjustedToIdx =
|
||||
fromDayIdx === toDayIdx && toItemIdx > fromItemIdx
|
||||
? toItemIdx - 1
|
||||
: toItemIdx;
|
||||
next[toDayIdx].items.splice(adjustedToIdx, 0, movedItem);
|
||||
|
||||
return next;
|
||||
});
|
||||
|
||||
return { valid: true, severity: 'warn' };
|
||||
},
|
||||
[editableDays, poisMap, eventsMap],
|
||||
);
|
||||
|
||||
const addItem = useCallback(
|
||||
(dayIdx: number, item: ItineraryItem): ValidationResult => {
|
||||
if (dayIdx < 0 || dayIdx >= editableDays.length) {
|
||||
return { valid: false, reason: 'Неверный индекс дня', severity: 'block' };
|
||||
}
|
||||
|
||||
const validation = validateItemPlacement(
|
||||
item,
|
||||
editableDays[dayIdx].date,
|
||||
editableDays[dayIdx].items,
|
||||
poisMap,
|
||||
eventsMap,
|
||||
);
|
||||
|
||||
if (!validation.valid) {
|
||||
return validation;
|
||||
}
|
||||
|
||||
setEditableDays((prev) => {
|
||||
const next = prev.map((d) => ({
|
||||
date: d.date,
|
||||
items: [...d.items],
|
||||
}));
|
||||
next[dayIdx].items.push(item);
|
||||
return next;
|
||||
});
|
||||
|
||||
return { valid: true, severity: 'warn' };
|
||||
},
|
||||
[editableDays, poisMap, eventsMap],
|
||||
);
|
||||
|
||||
const removeItem = useCallback((dayIdx: number, itemIdx: number) => {
|
||||
setEditableDays((prev) => {
|
||||
const next = prev.map((d) => ({
|
||||
date: d.date,
|
||||
items: [...d.items],
|
||||
}));
|
||||
if (dayIdx >= 0 && dayIdx < next.length) {
|
||||
next[dayIdx].items.splice(itemIdx, 1);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const addCustomItem = useCallback(
|
||||
(
|
||||
dayIdx: number,
|
||||
title: string,
|
||||
refType: CustomItemType,
|
||||
durationMin: number,
|
||||
): ValidationResult => {
|
||||
const newItem: ItineraryItem = {
|
||||
refType,
|
||||
refId: `custom-${Date.now()}`,
|
||||
title,
|
||||
lat: 0,
|
||||
lng: 0,
|
||||
note: `~${durationMin} мин`,
|
||||
};
|
||||
return addItem(dayIdx, newItem);
|
||||
},
|
||||
[addItem],
|
||||
);
|
||||
|
||||
const applyChanges = useCallback(async (): Promise<LLMValidationResponse | null> => {
|
||||
if (!onValidateWithLLM) return null;
|
||||
|
||||
setIsValidating(true);
|
||||
setLlmWarnings([]);
|
||||
setLlmSuggestions([]);
|
||||
|
||||
try {
|
||||
const result = await onValidateWithLLM(editableDays);
|
||||
if (result) {
|
||||
setLlmWarnings(result.warnings);
|
||||
setLlmSuggestions(result.suggestions);
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
}, [editableDays, onValidateWithLLM]);
|
||||
|
||||
return {
|
||||
editableDays,
|
||||
isEditing,
|
||||
hasChanges,
|
||||
isValidating,
|
||||
llmWarnings,
|
||||
llmSuggestions,
|
||||
startEditing,
|
||||
stopEditing,
|
||||
resetChanges,
|
||||
moveItem,
|
||||
addItem,
|
||||
removeItem,
|
||||
addCustomItem,
|
||||
applyChanges,
|
||||
validatePlacement: (item: ItineraryItem, dayIdx: number) =>
|
||||
validateItemPlacement(
|
||||
item,
|
||||
editableDays[dayIdx]?.date ?? '',
|
||||
editableDays[dayIdx]?.items ?? [],
|
||||
poisMap,
|
||||
eventsMap,
|
||||
),
|
||||
};
|
||||
}
|
||||
282
backend/webui/src/lib/hooks/useLearningChat.ts
Normal file
282
backend/webui/src/lib/hooks/useLearningChat.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import type { StreamEvent, LearningPhase } from '../types';
|
||||
import { generateId } from '../api';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
|
||||
export interface LearningChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
isStreaming?: boolean;
|
||||
widgets?: Array<{ type: string; data: Record<string, unknown> }>;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface UseLearningChatOptions {
|
||||
enrollmentId?: string;
|
||||
courseTitle?: string;
|
||||
profileContext?: string;
|
||||
planContext?: string;
|
||||
}
|
||||
|
||||
export function useLearningChat(options: UseLearningChatOptions = {}) {
|
||||
const [messages, setMessages] = useState<LearningChatMessage[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [loadingPhase, setLoadingPhase] = useState<LearningPhase>('idle');
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const sendMessage = useCallback(async (content: string) => {
|
||||
if (!content.trim() || isLoading) return;
|
||||
|
||||
const userMessage: LearningChatMessage = {
|
||||
id: generateId(),
|
||||
role: 'user',
|
||||
content: content.trim(),
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const assistantMessage: LearningChatMessage = {
|
||||
id: generateId(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
isStreaming: true,
|
||||
widgets: [],
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, userMessage, assistantMessage]);
|
||||
setIsLoading(true);
|
||||
setLoadingPhase('taskDesign');
|
||||
|
||||
const history: [string, string][] = messages
|
||||
.filter((m) => !m.isStreaming)
|
||||
.reduce((acc, m, i, arr) => {
|
||||
if (m.role === 'user' && arr[i + 1]?.role === 'assistant') {
|
||||
acc.push([m.content, arr[i + 1].content]);
|
||||
}
|
||||
return acc;
|
||||
}, [] as [string, string][]);
|
||||
|
||||
const systemInstructions = [
|
||||
options.courseTitle ? `Текущий курс: ${options.courseTitle}` : '',
|
||||
options.enrollmentId ? `ID записи: ${options.enrollmentId}` : '',
|
||||
options.planContext ? `План обучения: ${options.planContext}` : '',
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
try {
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
|
||||
const headers: HeadersInit = { 'Content-Type': 'application/json' };
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
const response = await fetch(`${API_BASE}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
signal: abortController.signal,
|
||||
body: JSON.stringify({
|
||||
message: {
|
||||
messageId: assistantMessage.id,
|
||||
chatId: userMessage.id,
|
||||
content: content.trim(),
|
||||
},
|
||||
optimizationMode: 'balanced',
|
||||
history,
|
||||
locale: 'ru',
|
||||
answerMode: 'learning',
|
||||
learningMode: true,
|
||||
systemInstructions,
|
||||
chatModel: { providerId: '', key: '' },
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`Chat failed: ${response.status}`);
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error('No response body');
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let fullContent = '';
|
||||
const collectedWidgets: Array<{ type: string; data: Record<string, unknown> }> = [];
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const event = JSON.parse(line) as StreamEvent;
|
||||
processLearningEvent(event, assistantMessage.id, {
|
||||
setMessages,
|
||||
setLoadingPhase,
|
||||
fullContent: { get: () => fullContent, set: (v: string) => { fullContent = v; } },
|
||||
collectedWidgets,
|
||||
});
|
||||
} catch {
|
||||
// skip unparseable lines
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.trim()) {
|
||||
try {
|
||||
const event = JSON.parse(buffer) as StreamEvent;
|
||||
processLearningEvent(event, assistantMessage.id, {
|
||||
setMessages,
|
||||
setLoadingPhase,
|
||||
fullContent: { get: () => fullContent, set: (v: string) => { fullContent = v; } },
|
||||
collectedWidgets,
|
||||
});
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantMessage.id
|
||||
? { ...m, isStreaming: false, widgets: collectedWidgets }
|
||||
: m
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
if ((error as Error).name === 'AbortError') {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantMessage.id ? { ...m, isStreaming: false } : m
|
||||
)
|
||||
);
|
||||
} else {
|
||||
const errMsg = error instanceof Error ? error.message : 'Ошибка';
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantMessage.id
|
||||
? { ...m, content: `Ошибка: ${errMsg}`, isStreaming: false }
|
||||
: m
|
||||
)
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setLoadingPhase('idle');
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
}, [isLoading, messages, options]);
|
||||
|
||||
const stopGeneration = useCallback(() => {
|
||||
abortControllerRef.current?.abort();
|
||||
setIsLoading(false);
|
||||
setLoadingPhase('idle');
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m))
|
||||
);
|
||||
}, []);
|
||||
|
||||
const clearChat = useCallback(() => {
|
||||
setMessages([]);
|
||||
setLoadingPhase('idle');
|
||||
}, []);
|
||||
|
||||
return {
|
||||
messages,
|
||||
isLoading,
|
||||
loadingPhase,
|
||||
sendMessage,
|
||||
stopGeneration,
|
||||
clearChat,
|
||||
};
|
||||
}
|
||||
|
||||
interface ProcessContext {
|
||||
setMessages: React.Dispatch<React.SetStateAction<LearningChatMessage[]>>;
|
||||
setLoadingPhase: React.Dispatch<React.SetStateAction<LearningPhase>>;
|
||||
fullContent: { get: () => string; set: (v: string) => void };
|
||||
collectedWidgets: Array<{ type: string; data: Record<string, unknown> }>;
|
||||
}
|
||||
|
||||
function processLearningEvent(
|
||||
event: StreamEvent,
|
||||
assistantId: string,
|
||||
ctx: ProcessContext
|
||||
): void {
|
||||
switch (event.type) {
|
||||
case 'textChunk': {
|
||||
if (event.chunk && typeof event.chunk === 'string') {
|
||||
const newContent = ctx.fullContent.get() + event.chunk;
|
||||
ctx.fullContent.set(newContent);
|
||||
ctx.setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantId ? { ...m, content: newContent } : m
|
||||
)
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'block': {
|
||||
if (event.block && typeof event.block === 'object') {
|
||||
const block = event.block as { type: string; data: unknown };
|
||||
if (block.type === 'text' && typeof block.data === 'string') {
|
||||
ctx.fullContent.set(block.data);
|
||||
ctx.setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantId ? { ...m, content: block.data as string } : m
|
||||
)
|
||||
);
|
||||
} else if (block.type === 'widget' && block.data) {
|
||||
const widget = block.data as { type: string; params: Record<string, unknown> };
|
||||
ctx.collectedWidgets.push({ type: widget.type, data: widget.params || {} });
|
||||
|
||||
if (widget.type === 'learning_task') {
|
||||
ctx.setLoadingPhase('taskDesign');
|
||||
} else if (widget.type === 'learning_plan') {
|
||||
ctx.setLoadingPhase('planBuilding');
|
||||
} else if (widget.type === 'learning_quiz') {
|
||||
ctx.setLoadingPhase('onboarding');
|
||||
} else if (widget.type === 'learning_evaluation') {
|
||||
ctx.setLoadingPhase('verifying');
|
||||
} else if (widget.type === 'learning_sandbox_status') {
|
||||
ctx.setLoadingPhase('verifying');
|
||||
} else if (widget.type === 'learning_progress') {
|
||||
ctx.setLoadingPhase('idle');
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'updateBlock': {
|
||||
if (event.patch && Array.isArray(event.patch)) {
|
||||
for (const p of event.patch) {
|
||||
if (p.op === 'replace' && p.path === '/data' && typeof p.value === 'string') {
|
||||
ctx.fullContent.set(p.value);
|
||||
ctx.setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantId ? { ...m, content: p.value as string } : m
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'researchComplete':
|
||||
ctx.setLoadingPhase('idle');
|
||||
break;
|
||||
|
||||
case 'messageEnd':
|
||||
ctx.setLoadingPhase('idle');
|
||||
break;
|
||||
}
|
||||
}
|
||||
140
backend/webui/src/lib/hooks/useMedicineChat.ts
Normal file
140
backend/webui/src/lib/hooks/useMedicineChat.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { streamMedicineConsult } from '../api';
|
||||
|
||||
export interface MedicineWidget {
|
||||
id: string;
|
||||
type: string;
|
||||
params: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface MedicineChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
isStreaming?: boolean;
|
||||
widgets: MedicineWidget[];
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
}
|
||||
|
||||
export function useMedicineChat() {
|
||||
const [messages, setMessages] = useState<MedicineChatMessage[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const historyRef = useRef<[string, string][]>([]);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (content: string, city?: string) => {
|
||||
if (!content.trim() || isLoading) return;
|
||||
|
||||
const userMessage: MedicineChatMessage = {
|
||||
id: generateId(),
|
||||
role: 'user',
|
||||
content: content.trim(),
|
||||
widgets: [],
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const assistantMessage: MedicineChatMessage = {
|
||||
id: generateId(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
isStreaming: true,
|
||||
widgets: [],
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, userMessage, assistantMessage]);
|
||||
setIsLoading(true);
|
||||
|
||||
abortControllerRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current = controller;
|
||||
|
||||
try {
|
||||
let fullContent = '';
|
||||
const widgets: MedicineWidget[] = [];
|
||||
|
||||
const stream = streamMedicineConsult(
|
||||
content.trim(),
|
||||
city?.trim() || undefined,
|
||||
historyRef.current,
|
||||
controller.signal
|
||||
);
|
||||
|
||||
for await (const event of stream) {
|
||||
const chunkText = event.chunk || (event.data as Record<string, unknown>)?.chunk;
|
||||
if (event.type === 'textChunk' && typeof chunkText === 'string') {
|
||||
fullContent += chunkText;
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.id === assistantMessage.id ? { ...m, content: fullContent } : m))
|
||||
);
|
||||
}
|
||||
|
||||
if (event.type === 'block' && event.block?.type === 'widget') {
|
||||
const data = event.block.data as { widgetType?: string; params?: Record<string, unknown> };
|
||||
if (!data.widgetType) continue;
|
||||
widgets.push({
|
||||
id: event.block.id,
|
||||
type: data.widgetType,
|
||||
params: data.params || {},
|
||||
});
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.id === assistantMessage.id ? { ...m, widgets: [...widgets] } : m))
|
||||
);
|
||||
}
|
||||
|
||||
if (event.type === 'messageEnd') {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantMessage.id
|
||||
? { ...m, isStreaming: false, content: fullContent, widgets: [...widgets] }
|
||||
: m
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (fullContent.trim()) {
|
||||
historyRef.current.push([userMessage.content, fullContent]);
|
||||
historyRef.current = historyRef.current.slice(-8);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Ошибка консультации';
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantMessage.id ? { ...m, content: `Ошибка: ${message}`, isStreaming: false } : m
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
},
|
||||
[isLoading]
|
||||
);
|
||||
|
||||
const stopGeneration = useCallback(() => {
|
||||
abortControllerRef.current?.abort();
|
||||
setIsLoading(false);
|
||||
setMessages((prev) => prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)));
|
||||
}, []);
|
||||
|
||||
const clearChat = useCallback(() => {
|
||||
setMessages([]);
|
||||
historyRef.current = [];
|
||||
}, []);
|
||||
|
||||
return {
|
||||
messages,
|
||||
isLoading,
|
||||
sendMessage,
|
||||
stopGeneration,
|
||||
clearChat,
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import type {
|
||||
TravelMessage,
|
||||
RoutePoint,
|
||||
TravelSuggestion,
|
||||
TravelPreferences,
|
||||
@@ -59,6 +58,13 @@ export function useTravelChat(options: UseTravelChatOptions = {}) {
|
||||
const [routeDirection, setRouteDirection] = useState<RouteDirection | null>(null);
|
||||
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([]);
|
||||
const [isResearching, setIsResearching] = useState(false);
|
||||
const [loadingPhase, setLoadingPhase] = useState<'idle' | 'planning' | 'collecting' | 'building' | 'routing'>('idle');
|
||||
|
||||
const [selectedEventIds, setSelectedEventIds] = useState<Set<string>>(new Set());
|
||||
const [selectedPOIIds, setSelectedPOIIds] = useState<Set<string>>(new Set());
|
||||
const [selectedHotelId, setSelectedHotelId] = useState<string | undefined>();
|
||||
const [selectedTransportId, setSelectedTransportId] = useState<string | undefined>();
|
||||
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const chatIdRef = useRef<string>(generateId());
|
||||
const lastUserQueryRef = useRef<string>('');
|
||||
@@ -108,9 +114,14 @@ export function useTravelChat(options: UseTravelChatOptions = {}) {
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
abortControllerRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current = controller;
|
||||
|
||||
setMessages((prev) => [...prev, userMessage, assistantMessage]);
|
||||
setIsLoading(true);
|
||||
setIsResearching(true);
|
||||
setLoadingPhase('planning');
|
||||
setClarifyingQuestions([]);
|
||||
|
||||
const history: [string, string][] = messages
|
||||
@@ -127,7 +138,7 @@ export function useTravelChat(options: UseTravelChatOptions = {}) {
|
||||
const effectiveOptions = planOptions || (isClarify ? lastPlanOptionsRef.current : undefined);
|
||||
|
||||
try {
|
||||
const stream = streamTravelAgent(messageContent, history, effectiveOptions, chatIdRef.current);
|
||||
const stream = streamTravelAgent(messageContent, history, effectiveOptions, chatIdRef.current, controller.signal);
|
||||
|
||||
let fullContent = '';
|
||||
const collectedWidgets: TravelWidget[] = [];
|
||||
@@ -152,9 +163,18 @@ export function useTravelChat(options: UseTravelChatOptions = {}) {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'research':
|
||||
case 'research': {
|
||||
setIsResearching(true);
|
||||
const substep = (block.data as Record<string, unknown>)?.substep as string | undefined;
|
||||
if (substep?.includes('collect') || substep?.includes('search')) {
|
||||
setLoadingPhase('collecting');
|
||||
} else if (substep?.includes('itinerary') || substep?.includes('build')) {
|
||||
setLoadingPhase('building');
|
||||
} else if (substep?.includes('route') || substep?.includes('routing')) {
|
||||
setLoadingPhase('routing');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'widget': {
|
||||
const widgetData = block.data as { widgetType: string; params: Record<string, unknown> };
|
||||
@@ -200,18 +220,30 @@ export function useTravelChat(options: UseTravelChatOptions = {}) {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Ошибка при планировании';
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantMessage.id
|
||||
? { ...m, content: `Ошибка: ${errorMessage}`, isStreaming: false }
|
||||
: m
|
||||
)
|
||||
);
|
||||
options.onError?.(error instanceof Error ? error : new Error(errorMessage));
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantMessage.id
|
||||
? { ...m, content: m.content || 'Генерация остановлена', isStreaming: false }
|
||||
: m
|
||||
)
|
||||
);
|
||||
} else {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Ошибка при планировании';
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantMessage.id
|
||||
? { ...m, content: `Ошибка: ${errorMessage}`, isStreaming: false }
|
||||
: m
|
||||
)
|
||||
);
|
||||
options.onError?.(error instanceof Error ? error : new Error(errorMessage));
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsResearching(false);
|
||||
setLoadingPhase('idle');
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
}, [isLoading, messages, options]);
|
||||
|
||||
@@ -225,13 +257,7 @@ export function useTravelChat(options: UseTravelChatOptions = {}) {
|
||||
options.onMapPointsUpdate?.(points);
|
||||
|
||||
if (params.routeDirection) {
|
||||
const rd = params.routeDirection as RouteDirection;
|
||||
console.log('[useTravelChat] routeDirection received:', {
|
||||
coordsCount: rd?.geometry?.coordinates?.length ?? 0,
|
||||
distance: rd?.distance,
|
||||
stepsCount: rd?.steps?.length ?? 0,
|
||||
});
|
||||
setRouteDirection(rd);
|
||||
setRouteDirection(params.routeDirection as RouteDirection);
|
||||
}
|
||||
|
||||
if (params.segments) {
|
||||
@@ -349,6 +375,12 @@ export function useTravelChat(options: UseTravelChatOptions = {}) {
|
||||
setBudget(null);
|
||||
setClarifyingQuestions([]);
|
||||
setSuggestions([]);
|
||||
setRouteDirection(null);
|
||||
setRouteSegments([]);
|
||||
setSelectedEventIds(new Set());
|
||||
setSelectedPOIIds(new Set());
|
||||
setSelectedHotelId(undefined);
|
||||
setSelectedTransportId(undefined);
|
||||
chatIdRef.current = generateId();
|
||||
lastUserQueryRef.current = '';
|
||||
lastPlanOptionsRef.current = {};
|
||||
@@ -380,7 +412,34 @@ export function useTravelChat(options: UseTravelChatOptions = {}) {
|
||||
sendMessage(`_clarify:${combinedMessage}`);
|
||||
}, [sendMessage]);
|
||||
|
||||
function buildSearchPrompt(scope: string, query: string | undefined): string {
|
||||
const q = (query || '').trim();
|
||||
const base = q ? `Запрос пользователя: "${q}".` : 'Запрос пользователя: найти больше вариантов.';
|
||||
switch (scope) {
|
||||
case 'places':
|
||||
return `${base}\n\nНайди ещё места/достопримечательности (POI) по текущему направлению поездки. Верни только релевантные варианты с координатами, рейтингом, адресом и кратким описанием.`;
|
||||
case 'events':
|
||||
return `${base}\n\nНайди ещё мероприятия и события по датам поездки. Верни релевантные варианты с датами, адресом, ссылкой и координатами если возможно.`;
|
||||
case 'hotels':
|
||||
return `${base}\n\nНайди ещё варианты отелей по датам поездки. Верни несколько категорий (дешевле/средний/комфорт) с ценой, рейтингом, адресом и координатами.`;
|
||||
case 'tickets':
|
||||
return `${base}\n\nПодбери билеты (перелёты/поезда) из города вылета к месту назначения и обратно по датам поездки. Верни варианты с ценой, временем, длительностью, пересадками и ссылкой.`;
|
||||
case 'transport':
|
||||
return `${base}\n\nПодбери транспорт на месте (трансферы/общественный/такси/аренда) для перемещений по маршруту. Верни варианты с примерной ценой/длительностью и рекомендациями.`;
|
||||
default:
|
||||
return `${base}\n\nНайди ещё варианты мест, отелей и мероприятий.`;
|
||||
}
|
||||
}
|
||||
|
||||
const handleAction = useCallback((actionKind: string) => {
|
||||
if (actionKind.startsWith('search:')) {
|
||||
// format: search:<scope> or search:<scope>:<query>
|
||||
const parts = actionKind.split(':');
|
||||
const scope = parts[1] || 'all';
|
||||
const query = parts.slice(2).join(':') || undefined;
|
||||
sendMessage(buildSearchPrompt(scope, query));
|
||||
return;
|
||||
}
|
||||
switch (actionKind) {
|
||||
case 'save':
|
||||
break;
|
||||
@@ -448,10 +507,49 @@ export function useTravelChat(options: UseTravelChatOptions = {}) {
|
||||
}
|
||||
}, [options]);
|
||||
|
||||
const toggleEventSelection = useCallback((event: EventCard) => {
|
||||
setSelectedEventIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(event.id)) {
|
||||
next.delete(event.id);
|
||||
} else {
|
||||
next.add(event.id);
|
||||
addEventToRoute(event);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [addEventToRoute]);
|
||||
|
||||
const togglePOISelection = useCallback((poi: POICard) => {
|
||||
setSelectedPOIIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(poi.id)) {
|
||||
next.delete(poi.id);
|
||||
} else {
|
||||
next.add(poi.id);
|
||||
addPOIToRoute(poi);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [addPOIToRoute]);
|
||||
|
||||
const toggleHotelSelection = useCallback((hotel: HotelCard) => {
|
||||
setSelectedHotelId((prev) => {
|
||||
const newId = prev === hotel.id ? undefined : hotel.id;
|
||||
if (newId) selectHotelOnRoute(hotel);
|
||||
return newId;
|
||||
});
|
||||
}, [selectHotelOnRoute]);
|
||||
|
||||
const toggleTransportSelection = useCallback((option: TransportOption) => {
|
||||
setSelectedTransportId((prev) => (prev === option.id ? undefined : option.id));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
messages,
|
||||
isLoading,
|
||||
isResearching,
|
||||
loadingPhase,
|
||||
currentRoute,
|
||||
mapPoints,
|
||||
events,
|
||||
@@ -464,6 +562,10 @@ export function useTravelChat(options: UseTravelChatOptions = {}) {
|
||||
suggestions,
|
||||
routeDirection,
|
||||
routeSegments,
|
||||
selectedEventIds,
|
||||
selectedPOIIds,
|
||||
selectedHotelId,
|
||||
selectedTransportId,
|
||||
sendMessage,
|
||||
stopGeneration,
|
||||
clearChat,
|
||||
@@ -474,5 +576,9 @@ export function useTravelChat(options: UseTravelChatOptions = {}) {
|
||||
addEventToRoute,
|
||||
addPOIToRoute,
|
||||
selectHotelOnRoute,
|
||||
toggleEventSelection,
|
||||
togglePOISelection,
|
||||
toggleHotelSelection,
|
||||
toggleTransportSelection,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -44,7 +44,18 @@ export type WidgetType =
|
||||
| 'travel_budget'
|
||||
| 'travel_clarifying'
|
||||
| 'travel_actions'
|
||||
| 'travel_context';
|
||||
| 'travel_context'
|
||||
| 'learning_profile_card'
|
||||
| 'learning_plan'
|
||||
| 'learning_task'
|
||||
| 'learning_quiz'
|
||||
| 'learning_evaluation'
|
||||
| 'learning_sandbox_status'
|
||||
| 'learning_progress'
|
||||
| 'medicine_assessment'
|
||||
| 'medicine_doctors'
|
||||
| 'medicine_appointments'
|
||||
| 'medicine_reference';
|
||||
|
||||
export interface Chat {
|
||||
id: string;
|
||||
@@ -249,6 +260,7 @@ export interface FinanceMarket {
|
||||
region: string;
|
||||
}
|
||||
|
||||
// Legacy lesson types (kept for backward compat)
|
||||
export interface Lesson {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -301,6 +313,118 @@ export interface PracticeExercise {
|
||||
language: string;
|
||||
}
|
||||
|
||||
// --- New Learning Cabinet types ---
|
||||
|
||||
export interface LearningCourse {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
shortDescription: string;
|
||||
category: string;
|
||||
tags: string[];
|
||||
difficulty: string;
|
||||
durationHours: number;
|
||||
baseOutline: CourseOutline;
|
||||
landing: CourseLanding;
|
||||
coverImage?: string;
|
||||
status: string;
|
||||
enrolledCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CourseOutline {
|
||||
modules?: CourseModule[];
|
||||
total_hours?: number;
|
||||
difficulty_adjusted?: string;
|
||||
personalization_notes?: string;
|
||||
}
|
||||
|
||||
export interface CourseModule {
|
||||
index: number;
|
||||
title: string;
|
||||
description: string;
|
||||
skills: string[];
|
||||
estimated_hours: number;
|
||||
practice_focus: string;
|
||||
}
|
||||
|
||||
export interface CourseLanding {
|
||||
hero_title?: string;
|
||||
hero_subtitle?: string;
|
||||
benefits?: string[];
|
||||
target_audience?: string;
|
||||
outcomes?: string[];
|
||||
salary_range?: string;
|
||||
prerequisites?: string;
|
||||
faq?: Array<{ question: string; answer: string }>;
|
||||
}
|
||||
|
||||
export interface LearningUserProfile {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
profile: Record<string, unknown>;
|
||||
resumeFileId?: string;
|
||||
resumeExtractedText?: string;
|
||||
onboardingCompleted: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface LearningEnrollment {
|
||||
id: string;
|
||||
userId: string;
|
||||
courseId: string;
|
||||
status: string;
|
||||
plan: CourseOutline;
|
||||
progress: EnrollmentProgress;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
course?: LearningCourse;
|
||||
}
|
||||
|
||||
export interface EnrollmentProgress {
|
||||
completed_modules: number[];
|
||||
current_module: number;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface LearningTask {
|
||||
id: string;
|
||||
enrollmentId: string;
|
||||
moduleIndex: number;
|
||||
title: string;
|
||||
taskType: string;
|
||||
instructionsMd: string;
|
||||
rubric: Record<string, unknown>;
|
||||
sandboxTemplate: Record<string, unknown>;
|
||||
verificationCmd?: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface LearningSubmission {
|
||||
id: string;
|
||||
taskId: string;
|
||||
sandboxSessionId?: string;
|
||||
result: Record<string, unknown>;
|
||||
score: number;
|
||||
maxScore: number;
|
||||
feedbackMd?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type LearningPhase =
|
||||
| 'idle'
|
||||
| 'onboarding'
|
||||
| 'resumeParsing'
|
||||
| 'profiling'
|
||||
| 'planBuilding'
|
||||
| 'taskDesign'
|
||||
| 'sandboxPreparing'
|
||||
| 'verifying';
|
||||
|
||||
export type UserRole = 'user' | 'admin';
|
||||
export type UserTier = 'free' | 'pro' | 'business';
|
||||
|
||||
@@ -371,6 +495,58 @@ export interface AdminPostUpdateRequest {
|
||||
status?: string;
|
||||
}
|
||||
|
||||
// --- Connectors (Settings) ---
|
||||
|
||||
export type ConnectorCategory = 'search' | 'data' | 'finance' | 'notifications';
|
||||
|
||||
export type ConnectorId =
|
||||
| 'web_search'
|
||||
| 'duckduckgo'
|
||||
| 'rss'
|
||||
| 'coincap'
|
||||
| 'exchangerate'
|
||||
| 'openmeteo'
|
||||
| 'github'
|
||||
| 'email'
|
||||
| 'telegram'
|
||||
| 'push'
|
||||
| 'websocket';
|
||||
|
||||
export type ConnectorAction = 'source' | 'notify';
|
||||
|
||||
export type ConnectorFieldType = 'text' | 'password' | 'textarea' | 'number' | 'url';
|
||||
|
||||
export interface ConnectorField {
|
||||
key: string;
|
||||
label: string;
|
||||
type: ConnectorFieldType;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
helpText?: string;
|
||||
}
|
||||
|
||||
export interface ConnectorConfig {
|
||||
id: ConnectorId;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
category: ConnectorCategory;
|
||||
requiresAuth: boolean;
|
||||
fields: ConnectorField[];
|
||||
actions: ConnectorAction[];
|
||||
}
|
||||
|
||||
export type UserConnectorStatus = 'connected' | 'disconnected' | 'error';
|
||||
|
||||
export interface UserConnector {
|
||||
id: ConnectorId;
|
||||
enabled: boolean;
|
||||
config: Record<string, string>;
|
||||
status: UserConnectorStatus;
|
||||
connectedAt?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface PlatformSettings {
|
||||
id: string;
|
||||
siteName: string;
|
||||
@@ -624,6 +800,19 @@ export interface EventCard {
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export type WeatherIcon = 'sun' | 'cloud' | 'cloud-sun' | 'rain' | 'storm' | 'snow' | 'fog' | 'wind';
|
||||
|
||||
export interface DailyForecast {
|
||||
date: string;
|
||||
tempMin: number;
|
||||
tempMax: number;
|
||||
conditions: string;
|
||||
icon: WeatherIcon;
|
||||
rainChance: string;
|
||||
wind?: string;
|
||||
tip?: string;
|
||||
}
|
||||
|
||||
export interface WeatherAssessment {
|
||||
summary: string;
|
||||
tempMin: number;
|
||||
@@ -631,6 +820,7 @@ export interface WeatherAssessment {
|
||||
conditions: string;
|
||||
clothing: string;
|
||||
rainChance: string;
|
||||
dailyForecast?: DailyForecast[];
|
||||
}
|
||||
|
||||
export interface SafetyAssessment {
|
||||
|
||||
49
backend/webui/src/middleware.ts
Normal file
49
backend/webui/src/middleware.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
const ALWAYS_ALLOWED = new Set(['/', '/settings', '/login', '/register', '/forgot-password', '/reset-password']);
|
||||
|
||||
const SYSTEM_PREFIXES = ['/_next', '/api', '/favicon', '/robots', '/sitemap'];
|
||||
|
||||
function parseEnabledRoutes(): Set<string> | null {
|
||||
const raw = process.env.NEXT_PUBLIC_ENABLED_ROUTES || '';
|
||||
if (!raw.trim()) return null;
|
||||
return new Set(
|
||||
raw
|
||||
.split(',')
|
||||
.map((r) => r.trim())
|
||||
.filter((r) => r.startsWith('/')),
|
||||
);
|
||||
}
|
||||
|
||||
const enabled = parseEnabledRoutes();
|
||||
|
||||
function isAllowed(pathname: string): boolean {
|
||||
if (ALWAYS_ALLOWED.has(pathname)) return true;
|
||||
if (SYSTEM_PREFIXES.some((p) => pathname.startsWith(p))) return true;
|
||||
if (enabled === null) return true;
|
||||
|
||||
for (const route of Array.from(enabled)) {
|
||||
if (pathname === route || pathname.startsWith(route + '/')) return true;
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/admin')) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function middleware(request: NextRequest): NextResponse | undefined {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
if (!isAllowed(pathname)) {
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = '/';
|
||||
return NextResponse.redirect(url, 302);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
|
||||
};
|
||||
Reference in New Issue
Block a user