feat: Go backend, enhanced search, new widgets, Docker deploy

Major changes:
- Add Go backend (backend/) with microservices architecture
- Enhanced master-agents-svc: reranker, content-classifier, stealth-crawler,
  proxy-manager, media-search, fastClassifier, language detection
- New web-svc widgets: KnowledgeCard, ProductCard, ProfileCard, VideoCard,
  UnifiedCard, CardGallery, InlineImageGallery, SourcesPanel, RelatedQuestions
- Improved discover-svc with discover-db integration
- Docker deployment improvements (Caddyfile, vendor.sh, BUILD.md)
- Library-svc: project_id schema migration
- Remove deprecated finance-svc and travel-svc
- Localization improvements across services

Made-with: Cursor
This commit is contained in:
home
2026-02-27 04:15:32 +03:00
parent 328d968f3f
commit 06fe57c765
285 changed files with 53132 additions and 1871 deletions

View File

@@ -0,0 +1,752 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Cpu,
Play,
Pause,
Square,
Clock,
Zap,
Calendar,
CheckCircle2,
XCircle,
Loader2,
ChevronRight,
FileCode,
FileText,
Image as ImageIcon,
Database,
Download,
RefreshCw,
AlertCircle,
Timer,
DollarSign,
Layers,
Bot,
Sparkles,
ArrowRight,
Settings2,
Send,
Globe,
Code2,
BarChart3,
Mail,
MessageCircle,
Webhook,
HardDrive,
Menu,
ArrowLeft,
X,
} from 'lucide-react';
import type {
ComputerTask,
ComputerTaskStatus,
DurationMode,
Artifact,
ComputerTaskEvent,
} from '@/lib/types';
import {
executeComputerTask,
fetchComputerTasks,
cancelComputerTask,
streamComputerTask,
fetchComputerArtifacts,
downloadArtifact,
} from '@/lib/api';
const durationModes: { value: DurationMode; label: string; desc: string; icon: React.ElementType }[] = [
{ value: 'short', label: '30 мин', desc: 'Быстрые', icon: Zap },
{ value: 'medium', label: '4 часа', desc: 'Стандартные', icon: Clock },
{ value: 'long', label: '24 часа', desc: 'Комплексные', icon: Calendar },
{ value: 'extended', label: '7 дней', desc: 'Мониторинг', icon: Timer },
{ value: 'unlimited', label: '∞', desc: 'Без лимита', icon: Sparkles },
];
const taskExamples = [
{
icon: BarChart3,
title: 'Исследование конкурентов',
query: 'Проанализируй топ-5 конкурентов в сфере e-commerce в России',
color: 'text-blue-400',
bgColor: 'bg-blue-500/10',
},
{
icon: Code2,
title: 'Разработка дашборда',
query: 'Создай дашборд для отслеживания курсов криптовалют',
color: 'text-emerald-400',
bgColor: 'bg-emerald-500/10',
},
{
icon: Globe,
title: 'Мониторинг новостей',
query: 'Мониторь новости по теме AI в медицине каждые 6 часов',
color: 'text-purple-400',
bgColor: 'bg-purple-500/10',
},
{
icon: FileText,
title: 'Генерация отчёта',
query: 'Исследуй рынок EdTech и создай PDF-отчёт с визуализациями',
color: 'text-orange-400',
bgColor: 'bg-orange-500/10',
},
];
const statusConfig: Record<ComputerTaskStatus, { color: string; bg: string; icon: React.ElementType; label: string }> = {
pending: { color: 'text-secondary', bg: 'bg-surface/60', icon: Clock, label: 'Ожидание' },
planning: { color: 'text-blue-400', bg: 'bg-blue-400/10', icon: Bot, label: 'Планирование' },
executing: { color: 'text-success', bg: 'bg-success/10', icon: Play, label: 'Выполнение' },
long_running: { color: 'text-success', bg: 'bg-success/10', icon: Loader2, label: 'Долгая задача' },
waiting_user: { color: 'text-warning', bg: 'bg-warning/10', icon: AlertCircle, label: 'Ожидает ввода' },
completed: { color: 'text-success', bg: 'bg-success/10', icon: CheckCircle2, label: 'Завершено' },
failed: { color: 'text-error', bg: 'bg-error/10', icon: XCircle, label: 'Ошибка' },
cancelled: { color: 'text-muted', bg: 'bg-surface/40', icon: Square, label: 'Отменено' },
scheduled: { color: 'text-accent-secondary', bg: 'bg-accent-secondary/10', icon: Calendar, label: 'Запланировано' },
paused: { color: 'text-warning', bg: 'bg-warning/10', icon: Pause, label: 'Пауза' },
checkpoint: { color: 'text-accent', bg: 'bg-accent/10', icon: RefreshCw, label: 'Чекпоинт' },
};
const artifactIcons: Record<string, React.ElementType> = {
file: FileText,
code: FileCode,
report: FileText,
deployment: Globe,
image: ImageIcon,
data: Database,
};
export default function ComputerPage() {
const [tasks, setTasks] = useState<ComputerTask[]>([]);
const [selectedTask, setSelectedTask] = useState<ComputerTask | null>(null);
const [query, setQuery] = useState('');
const [durationMode, setDurationMode] = useState<DurationMode>('medium');
const [isLoading, setIsLoading] = useState(false);
const [isExecuting, setIsExecuting] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
const [artifacts, setArtifacts] = useState<Artifact[]>([]);
const [events, setEvents] = useState<ComputerTaskEvent[]>([]);
const [showTaskList, setShowTaskList] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const eventsEndRef = useRef<HTMLDivElement>(null);
const loadTasks = useCallback(async () => {
setIsLoading(true);
try {
const result = await fetchComputerTasks(undefined, 50);
setTasks(result.tasks || []);
} catch (error) {
console.error('Failed to load tasks:', error);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
loadTasks();
}, [loadTasks]);
useEffect(() => {
eventsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [events]);
const handleExecute = async () => {
if (!query.trim()) return;
setIsExecuting(true);
setEvents([]);
try {
const task = await executeComputerTask({
query: query.trim(),
options: {
async: true,
durationMode,
enableSandbox: true,
enableBrowser: true,
},
});
setSelectedTask(task);
setTasks((prev) => [task, ...prev]);
setQuery('');
setShowTaskList(false);
streamTaskEvents(task.id);
} catch (error) {
console.error('Failed to execute task:', error);
} finally {
setIsExecuting(false);
}
};
const streamTaskEvents = async (taskId: string) => {
try {
for await (const event of streamComputerTask(taskId)) {
setEvents((prev) => [...prev, event]);
if (event.status) {
setSelectedTask((prev) =>
prev?.id === taskId ? { ...prev, status: event.status!, progress: event.progress ?? prev.progress } : prev
);
setTasks((prev) =>
prev.map((t) =>
t.id === taskId ? { ...t, status: event.status!, progress: event.progress ?? t.progress } : t
)
);
}
if (event.type === 'task_completed' || event.type === 'task_failed') {
loadArtifacts(taskId);
}
}
} catch (error) {
console.error('Stream error:', error);
}
};
const loadArtifacts = async (taskId: string) => {
try {
const result = await fetchComputerArtifacts(taskId);
setArtifacts(result.artifacts || []);
} catch (error) {
console.error('Failed to load artifacts:', error);
}
};
const handleCancel = async (taskId: string) => {
try {
await cancelComputerTask(taskId);
setTasks((prev) => prev.map((t) => (t.id === taskId ? { ...t, status: 'cancelled' } : t)));
if (selectedTask?.id === taskId) {
setSelectedTask((prev) => (prev ? { ...prev, status: 'cancelled' } : null));
}
} catch (error) {
console.error('Failed to cancel task:', error);
}
};
const handleDownload = async (artifact: Artifact) => {
try {
const blob = await downloadArtifact(artifact.id);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = artifact.name;
a.click();
URL.revokeObjectURL(url);
} catch (error) {
console.error('Failed to download artifact:', error);
}
};
const handleExample = (exampleQuery: string) => {
setQuery(exampleQuery);
textareaRef.current?.focus();
};
const handleSelectTask = (task: ComputerTask) => {
setSelectedTask(task);
setShowTaskList(false);
if (isTaskActive(task.status)) {
setEvents([]);
streamTaskEvents(task.id);
} else {
loadArtifacts(task.id);
}
};
const formatDuration = (ms: number): string => {
if (ms < 1000) return `${ms}мс`;
if (ms < 60000) return `${Math.round(ms / 1000)}с`;
if (ms < 3600000) return `${Math.round(ms / 60000)}м`;
return `${Math.round(ms / 3600000)}ч`;
};
const formatCost = (cost: number): string => {
return `$${cost.toFixed(4)}`;
};
const isTaskActive = (status: ComputerTaskStatus): boolean => {
return ['pending', 'planning', 'executing', 'long_running'].includes(status);
};
return (
<div className="flex h-full bg-gradient-main relative">
{/* Mobile Task List Overlay */}
<AnimatePresence>
{showTaskList && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setShowTaskList(false)}
className="fixed inset-0 z-40 bg-base/80 backdrop-blur-sm md:hidden"
/>
<motion.div
initial={{ x: -280 }}
animate={{ x: 0 }}
exit={{ x: -280 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
className="fixed left-0 top-0 bottom-0 z-50 w-[280px] bg-base border-r border-border/50 flex flex-col md:hidden"
>
<div className="p-4 border-b border-border/50 flex items-center justify-between">
<h2 className="text-lg font-semibold text-primary flex items-center gap-2">
<Cpu className="w-5 h-5 text-accent" />
Задачи
</h2>
<button
onClick={() => setShowTaskList(false)}
className="w-9 h-9 flex items-center justify-center rounded-xl text-muted hover:text-primary hover:bg-surface/50 transition-all"
>
<X className="w-5 h-5" />
</button>
</div>
<TaskList
tasks={tasks}
selectedTask={selectedTask}
isLoading={isLoading}
onSelect={handleSelectTask}
isTaskActive={isTaskActive}
statusConfig={statusConfig}
/>
</motion.div>
</>
)}
</AnimatePresence>
{/* Desktop Left Panel - Task List */}
<div className="hidden md:flex w-72 lg:w-80 border-r border-border/50 flex-col">
<div className="p-4 border-b border-border/50">
<h2 className="text-lg font-semibold text-primary flex items-center gap-2">
<Cpu className="w-5 h-5 text-accent" />
Computer
</h2>
<p className="text-xs text-muted mt-1">Автономные AI-задачи</p>
</div>
<TaskList
tasks={tasks}
selectedTask={selectedTask}
isLoading={isLoading}
onSelect={handleSelectTask}
isTaskActive={isTaskActive}
statusConfig={statusConfig}
/>
</div>
{/* Main Content */}
<div className="flex-1 flex flex-col overflow-hidden">
<AnimatePresence mode="wait">
{selectedTask ? (
<motion.div
key="task-detail"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex-1 flex flex-col overflow-hidden"
>
{/* Task Header */}
<div className="p-4 sm:p-6 border-b border-border/50">
<div className="flex items-start gap-3">
<button
onClick={() => setSelectedTask(null)}
className="w-10 h-10 flex items-center justify-center rounded-xl text-secondary hover:text-primary hover:bg-surface/50 transition-all flex-shrink-0"
>
<ArrowLeft className="w-5 h-5" />
</button>
<div className="flex-1 min-w-0">
<p className="text-base sm:text-lg text-primary font-medium line-clamp-2">{selectedTask.query}</p>
<div className="flex flex-wrap items-center gap-2 sm:gap-4 mt-2">
{(() => {
const config = statusConfig[selectedTask.status];
const StatusIcon = config.icon;
return (
<span className={`flex items-center gap-1.5 text-sm ${config.color}`}>
<StatusIcon className={`w-4 h-4 ${isTaskActive(selectedTask.status) ? 'animate-spin' : ''}`} />
{config.label}
</span>
);
})()}
<span className="text-sm text-muted flex items-center gap-1">
<Timer className="w-3.5 h-3.5" />
{formatDuration(selectedTask.totalRuntime)}
</span>
<span className="text-sm text-muted flex items-center gap-1">
<DollarSign className="w-3.5 h-3.5" />
{formatCost(selectedTask.totalCost)}
</span>
</div>
</div>
{isTaskActive(selectedTask.status) && (
<button
onClick={() => handleCancel(selectedTask.id)}
className="px-3 py-1.5 rounded-lg bg-error/10 text-error border border-error/30 hover:bg-error/20 transition-colors text-sm flex items-center gap-1.5 flex-shrink-0"
>
<Square className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Отменить</span>
</button>
)}
</div>
{/* Progress Bar */}
{isTaskActive(selectedTask.status) && (
<div className="mt-4">
<div className="h-2 bg-surface/60 rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${selectedTask.progress}%` }}
transition={{ duration: 0.5 }}
className="h-full bg-gradient-to-r from-accent to-accent-hover rounded-full"
/>
</div>
<div className="flex justify-between mt-1.5">
<span className="text-xs text-muted">Прогресс</span>
<span className="text-xs text-accent">{selectedTask.progress}%</span>
</div>
</div>
)}
</div>
{/* Task Content */}
<div className="flex-1 overflow-y-auto p-4 sm:p-6">
{/* Sub Tasks */}
{selectedTask.subTasks && selectedTask.subTasks.length > 0 && (
<div className="mb-6">
<h3 className="text-sm font-medium text-primary mb-3 flex items-center gap-2">
<Layers className="w-4 h-4" />
Подзадачи ({selectedTask.subTasks.length})
</h3>
<div className="space-y-2">
{selectedTask.subTasks.map((subtask) => {
const config = statusConfig[subtask.status];
const StatusIcon = config.icon;
return (
<div
key={subtask.id}
className="p-3 rounded-lg bg-elevated/40 border border-border/40"
>
<div className="flex items-center gap-3">
<StatusIcon
className={`w-4 h-4 ${config.color} flex-shrink-0 ${
isTaskActive(subtask.status) ? 'animate-spin' : ''
}`}
/>
<div className="flex-1 min-w-0">
<p className="text-sm text-primary truncate">{subtask.description}</p>
<div className="flex items-center gap-3 mt-1">
<span className="text-xs text-muted uppercase">{subtask.type}</span>
{subtask.cost > 0 && (
<span className="text-xs text-muted">{formatCost(subtask.cost)}</span>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{/* Events Log */}
{events.length > 0 && (
<div className="mb-6">
<h3 className="text-sm font-medium text-primary mb-3 flex items-center gap-2">
<RefreshCw className="w-4 h-4" />
События
</h3>
<div className="space-y-1 max-h-64 overflow-y-auto bg-base/50 rounded-lg p-3 border border-border/30">
{events.map((event, i) => (
<div key={i} className="flex items-start gap-2 text-xs">
<span className="text-faint font-mono whitespace-nowrap">
{new Date(event.timestamp).toLocaleTimeString()}
</span>
<span className="text-secondary break-all">{event.message || event.type}</span>
</div>
))}
<div ref={eventsEndRef} />
</div>
</div>
)}
{/* Artifacts */}
{artifacts.length > 0 && (
<div>
<h3 className="text-sm font-medium text-primary mb-3 flex items-center gap-2">
<FileCode className="w-4 h-4" />
Артефакты ({artifacts.length})
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{artifacts.map((artifact) => {
const Icon = artifactIcons[artifact.type] || FileText;
return (
<div
key={artifact.id}
className="p-4 rounded-lg bg-elevated/40 border border-border/40 hover:border-accent/30 transition-colors"
>
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-surface/60 flex-shrink-0">
<Icon className="w-5 h-5 text-accent" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-primary truncate">{artifact.name}</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-muted">{artifact.type}</span>
<span className="text-xs text-muted">
{(artifact.size / 1024).toFixed(1)} KB
</span>
</div>
</div>
<button
onClick={() => handleDownload(artifact)}
className="p-1.5 rounded-lg hover:bg-surface/60 text-muted hover:text-secondary transition-colors flex-shrink-0"
>
<Download className="w-4 h-4" />
</button>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
</motion.div>
) : (
<motion.div
key="new-task"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex-1 overflow-y-auto"
>
<div className="flex flex-col items-center justify-center min-h-full px-4 sm:px-6 py-8 pb-24">
{/* Mobile task list button */}
<div className="w-full max-w-2xl mb-6 md:hidden">
<button
onClick={() => setShowTaskList(true)}
className="flex items-center gap-2 px-4 py-2.5 text-sm text-secondary bg-surface/40 border border-border/50 rounded-xl hover:border-border transition-all"
>
<Menu className="w-4 h-4" />
Показать задачи ({tasks.length})
</button>
</div>
{/* Header */}
<div className="text-center mb-8 sm:mb-10">
<h1 className="text-2xl sm:text-3xl font-bold text-primary mb-2 sm:mb-3">
GooSeek <span className="text-gradient">Computer</span>
</h1>
<p className="text-sm sm:text-base text-secondary max-w-lg leading-relaxed px-4">
Автономный AI-агент для сложных задач: исследования, код, мониторинг.
</p>
</div>
{/* Task Input */}
<div className="w-full max-w-2xl mb-6 sm:mb-8">
<div className="relative">
<textarea
ref={textareaRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleExecute();
}
}}
placeholder="Опишите задачу..."
rows={3}
className="w-full px-4 py-4 pr-14 rounded-xl bg-elevated/60 border border-border text-primary placeholder-muted focus:outline-none focus:border-accent/50 focus:ring-2 focus:ring-accent/20 resize-none transition-all text-base"
/>
<button
onClick={handleExecute}
disabled={!query.trim() || isExecuting}
className={`absolute right-3 bottom-3 p-2.5 rounded-lg transition-all ${
query.trim()
? 'btn-gradient'
: 'bg-surface/50 text-muted'
} disabled:opacity-50 disabled:cursor-not-allowed`}
>
{isExecuting ? (
<Loader2 className="w-5 h-5 animate-spin btn-gradient-text" />
) : (
<Send className={`w-5 h-5 ${query.trim() ? 'btn-gradient-text' : ''}`} />
)}
</button>
</div>
</div>
{/* Duration Mode Selector */}
<div className="w-full max-w-2xl mb-6 sm:mb-8">
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-secondary">Режим выполнения</span>
<button
onClick={() => setShowAdvanced(!showAdvanced)}
className="text-xs text-muted hover:text-secondary flex items-center gap-1 transition-colors"
>
<Settings2 className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Расширенные</span>
<ChevronRight className={`w-3.5 h-3.5 transition-transform ${showAdvanced ? 'rotate-90' : ''}`} />
</button>
</div>
<div className="grid grid-cols-3 sm:grid-cols-5 gap-2">
{durationModes.map((mode) => (
<button
key={mode.value}
onClick={() => setDurationMode(mode.value)}
className={`p-2 sm:p-3 rounded-xl border text-center transition-all ${
durationMode === mode.value
? 'bg-accent/10 border-accent/30 text-primary'
: 'bg-elevated/40 border-border/40 text-secondary hover:border-border'
}`}
>
<mode.icon className={`w-4 sm:w-5 h-4 sm:h-5 mx-auto mb-1 ${
durationMode === mode.value ? 'text-accent' : 'text-muted'
}`} />
<div className="text-xs sm:text-sm font-medium">{mode.label}</div>
<div className="text-xs text-muted mt-0.5 hidden sm:block">{mode.desc}</div>
</button>
))}
</div>
</div>
{/* Advanced Settings */}
<AnimatePresence>
{showAdvanced && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="w-full max-w-2xl mb-6 sm:mb-8 overflow-hidden"
>
<div className="p-4 rounded-xl bg-elevated/40 border border-border/40">
<h4 className="text-sm font-medium text-primary mb-3">Коннекторы</h4>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{[
{ icon: Mail, label: 'Email', desc: 'Отправка' },
{ icon: MessageCircle, label: 'Telegram', desc: 'Уведомления' },
{ icon: Webhook, label: 'Webhook', desc: 'HTTP' },
{ icon: HardDrive, label: 'Storage', desc: 'S3' },
].map((conn) => (
<button
key={conn.label}
className="p-3 rounded-lg bg-surface/40 border border-border/50 hover:border-accent/30 transition-colors text-left"
>
<conn.icon className="w-4 h-4 text-accent mb-1.5" />
<div className="text-xs text-primary">{conn.label}</div>
<div className="text-xs text-muted hidden sm:block">{conn.desc}</div>
</button>
))}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Examples */}
<div className="w-full max-w-2xl lg:max-w-3xl">
<h3 className="text-sm text-muted mb-3 text-center">Примеры задач</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{taskExamples.map((example, i) => (
<motion.button
key={i}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.05 }}
onClick={() => handleExample(example.query)}
className="group relative p-3 sm:p-4 rounded-xl bg-elevated/40 border border-border/40 hover:border-accent/30 text-left transition-all"
>
<div className="flex items-center gap-3 mb-2">
<div className={`w-8 h-8 rounded-lg ${example.bgColor} flex items-center justify-center flex-shrink-0`}>
<example.icon className={`w-4 h-4 ${example.color}`} />
</div>
<span className="text-sm font-medium text-primary group-hover:text-accent transition-colors truncate">{example.title}</span>
</div>
<p className="text-xs text-secondary line-clamp-2 leading-relaxed">{example.query}</p>
<ArrowRight className="absolute bottom-3 right-3 w-4 h-4 text-transparent group-hover:text-accent/60 transition-all transform translate-x-2 group-hover:translate-x-0" />
</motion.button>
))}
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);
}
interface TaskListProps {
tasks: ComputerTask[];
selectedTask: ComputerTask | null;
isLoading: boolean;
onSelect: (task: ComputerTask) => void;
isTaskActive: (status: ComputerTaskStatus) => boolean;
statusConfig: Record<ComputerTaskStatus, { color: string; bg: string; icon: React.ElementType; label: string }>;
}
function TaskList({ tasks, selectedTask, isLoading, onSelect, isTaskActive, statusConfig }: TaskListProps) {
return (
<div className="flex-1 overflow-y-auto p-3 space-y-2">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 text-accent animate-spin" />
</div>
) : tasks.length === 0 ? (
<div className="text-center py-8 text-muted text-sm">
Нет активных задач
</div>
) : (
tasks.map((task) => {
const config = statusConfig[task.status];
const StatusIcon = config.icon;
return (
<motion.button
key={task.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
onClick={() => onSelect(task)}
className={`w-full text-left p-3 rounded-xl border transition-all ${
selectedTask?.id === task.id
? 'bg-elevated/60 border-accent/30'
: 'bg-elevated/40 border-border/40 hover:border-border'
}`}
>
<div className="flex items-start gap-3">
<div className={`p-1.5 rounded-lg ${config.bg} flex-shrink-0`}>
<StatusIcon
className={`w-4 h-4 ${config.color} ${
isTaskActive(task.status) ? 'animate-spin' : ''
}`}
/>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-primary line-clamp-2">{task.query}</p>
<div className="flex items-center gap-2 mt-1.5">
<span className={`text-xs ${config.color}`}>{config.label}</span>
{task.progress > 0 && task.progress < 100 && (
<span className="text-xs text-muted">{task.progress}%</span>
)}
</div>
{isTaskActive(task.status) && task.progress > 0 && (
<div className="mt-2 h-1 bg-surface/60 rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${task.progress}%` }}
className="h-full bg-gradient-to-r from-accent to-accent-hover"
/>
</div>
)}
</div>
</div>
</motion.button>
);
})
)}
</div>
);
}

View File

@@ -0,0 +1,153 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { motion } from 'framer-motion';
import { RefreshCw, ExternalLink, Loader2, Sparkles, Globe, Cpu, DollarSign, Dumbbell } from 'lucide-react';
import * as Tabs from '@radix-ui/react-tabs';
import { fetchDiscover } from '@/lib/api';
import type { DiscoverItem } from '@/lib/types';
const topics = [
{ id: 'tech', label: 'Технологии', icon: Cpu },
{ id: 'finance', label: 'Финансы', icon: DollarSign },
{ id: 'sports', label: 'Спорт', icon: Dumbbell },
] as const;
export default function DiscoverPage() {
const [items, setItems] = useState<DiscoverItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [topic, setTopic] = useState<string>('tech');
const load = useCallback(async () => {
setIsLoading(true);
try {
const data = await fetchDiscover(topic, 'russia');
setItems(data);
} catch {
setItems([]);
} finally {
setIsLoading(false);
}
}, [topic]);
useEffect(() => {
load();
}, [load]);
return (
<div className="h-full overflow-y-auto">
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-6 sm:py-8">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6 sm:mb-8">
<div>
<h1 className="text-xl sm:text-2xl font-semibold text-primary mb-1 sm:mb-2">Discover</h1>
<p className="text-sm text-secondary">Актуальные новости и события</p>
</div>
<button
onClick={load}
disabled={isLoading}
className="flex items-center gap-2 px-4 py-2.5 text-sm text-secondary hover:text-primary bg-surface/40 border border-border/50 hover:border-accent/30 rounded-xl transition-all w-fit"
>
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
<span className="hidden sm:inline">Обновить</span>
</button>
</div>
{/* Topic Tabs */}
<Tabs.Root value={topic} onValueChange={setTopic}>
<Tabs.List className="flex gap-2 mb-6 sm:mb-8 overflow-x-auto pb-1 -mx-4 px-4 sm:mx-0 sm:px-0">
{topics.map((t) => (
<Tabs.Trigger
key={t.id}
value={t.id}
className="flex items-center gap-2 px-4 py-2.5 text-sm rounded-xl border transition-all whitespace-nowrap data-[state=active]:bg-accent/10 data-[state=active]:border-accent/30 data-[state=active]:text-primary bg-surface/30 border-border/30 text-muted hover:text-secondary hover:border-border"
>
<t.icon className="w-4 h-4" />
{t.label}
</Tabs.Trigger>
))}
</Tabs.List>
</Tabs.Root>
{/* Content */}
{isLoading ? (
<div className="flex flex-col items-center justify-center py-16 sm:py-20">
<div className="relative">
<Loader2 className="w-8 h-8 animate-spin text-accent/60" />
<div className="absolute inset-0 blur-xl bg-accent/20" />
</div>
<p className="text-sm text-muted mt-4">Загрузка новостей...</p>
</div>
) : items.length === 0 ? (
<div className="text-center py-16 sm:py-20">
<Globe className="w-12 h-12 mx-auto mb-4 text-muted" />
<p className="text-secondary">Нет новостей по выбранной теме</p>
</div>
) : (
<div className="space-y-3 sm:space-y-4">
{items.map((item, i) => (
<motion.article
key={i}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.05 }}
className="group relative flex flex-col sm:flex-row gap-3 sm:gap-4 p-3 sm:p-4 bg-elevated/40 border border-border/40 rounded-xl hover:border-accent/25 hover:bg-elevated/60 transition-all duration-200"
>
{/* Thumbnail */}
{item.thumbnail && (
<div className="relative w-full sm:w-28 h-40 sm:h-20 rounded-lg overflow-hidden flex-shrink-0 bg-surface/50">
<img
src={item.thumbnail}
alt=""
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
</div>
)}
{/* Content */}
<div className="flex-1 min-w-0">
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-primary line-clamp-2 group-hover:text-accent transition-colors mb-2"
>
{item.title}
</a>
<p className="text-xs text-muted line-clamp-2 mb-3">
{item.content}
</p>
{/* Actions */}
<div className="flex flex-wrap items-center gap-3 sm:gap-4">
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-xs text-secondary hover:text-primary transition-colors"
>
<ExternalLink className="w-3.5 h-3.5" />
Читать
</a>
<button className="flex items-center gap-1.5 text-xs text-accent/80 hover:text-accent transition-colors">
<Sparkles className="w-3.5 h-3.5" />
AI Саммари
</button>
{item.sourcesCount && item.sourcesCount > 1 && (
<span className="text-xs text-faint">
{item.sourcesCount} источников
</span>
)}
</div>
</div>
</motion.article>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,265 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { motion } from 'framer-motion';
import { TrendingUp, TrendingDown, RefreshCw, Loader2, ArrowUpRight, ArrowDownRight, Activity, BarChart3 } from 'lucide-react';
import * as Tabs from '@radix-ui/react-tabs';
import { fetchMarkets, fetchHeatmap, fetchTopMovers } from '@/lib/api';
import type { FinanceMarket, HeatmapData, TopMovers, FinanceStock } from '@/lib/types';
const defaultMarkets: FinanceMarket[] = [
{ id: 'moex', name: 'MOEX', region: 'ru' },
{ id: 'crypto', name: 'Крипто', region: 'global' },
{ id: 'forex', name: 'Валюты', region: 'global' },
];
const timeRanges = [
{ id: '1d', label: '1Д' },
{ id: '1w', label: '1Н' },
{ id: '1m', label: '1М' },
{ id: '3m', label: '3М' },
{ id: '1y', label: '1Г' },
];
function formatPrice(price: number, market: string): string {
if (market === 'crypto' && price > 1000) {
return price.toLocaleString('ru-RU', { maximumFractionDigits: 0 });
}
return price.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function formatChange(change: number): string {
const prefix = change >= 0 ? '+' : '';
return `${prefix}${change.toFixed(2)}%`;
}
function StockRow({ stock, market, delay }: { stock: FinanceStock; market: string; delay: number }) {
const isPositive = stock.change >= 0;
return (
<motion.div
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay }}
className="flex items-center gap-3 sm:gap-4 p-2 sm:p-3 rounded-xl hover:bg-surface/40 transition-colors group"
>
<div className="w-9 h-9 sm:w-10 sm:h-10 rounded-xl bg-surface/60 flex items-center justify-center flex-shrink-0">
<span className="text-[10px] sm:text-xs font-semibold text-secondary">{stock.symbol.slice(0, 4)}</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-primary truncate group-hover:text-accent transition-colors">
{stock.name}
</p>
{stock.sector && (
<p className="text-xs text-muted truncate">{stock.sector}</p>
)}
</div>
<div className="text-right flex-shrink-0">
<p className="text-sm font-mono text-primary">{formatPrice(stock.price, market)}</p>
<div className={`flex items-center justify-end gap-1 text-xs font-mono ${isPositive ? 'text-success' : 'text-error'}`}>
{isPositive ? (
<TrendingUp className="w-3 h-3" />
) : (
<TrendingDown className="w-3 h-3" />
)}
{formatChange(stock.change)}
</div>
</div>
</motion.div>
);
}
function MoversSection({ title, stocks, market, icon: Icon, color }: {
title: string;
stocks: FinanceStock[];
market: string;
icon: React.ElementType;
color: string;
}) {
if (!stocks || stocks.length === 0) return null;
return (
<div className="mb-6 sm:mb-8">
<div className="flex items-center gap-3 mb-3 sm:mb-4">
<div className={`w-8 h-8 sm:w-9 sm:h-9 rounded-xl ${color} flex items-center justify-center`}>
<Icon className="w-4 h-4" />
</div>
<h3 className="text-sm font-medium text-primary">{title}</h3>
</div>
<div className="space-y-1 bg-elevated/40 border border-border/40 rounded-xl p-2">
{stocks.slice(0, 5).map((stock, i) => (
<StockRow key={stock.symbol} stock={stock} market={market} delay={i * 0.05} />
))}
</div>
</div>
);
}
export default function FinancePage() {
const [markets, setMarkets] = useState<FinanceMarket[]>(defaultMarkets);
const [currentMarket, setCurrentMarket] = useState('moex');
const [heatmapData, setHeatmapData] = useState<HeatmapData | null>(null);
const [moversData, setMoversData] = useState<TopMovers | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [timeRange, setTimeRange] = useState('1d');
const loadMarkets = useCallback(async () => {
try {
const data = await fetchMarkets();
if (data && data.length > 0) {
setMarkets(data);
}
} catch (err) {
console.error('Failed to load markets:', err);
}
}, []);
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const [heatmap, movers] = await Promise.all([
fetchHeatmap(currentMarket, timeRange).catch(() => null),
fetchTopMovers(currentMarket, 10).catch(() => null),
]);
setHeatmapData(heatmap);
setMoversData(movers);
} catch (err) {
console.error('Failed to load finance data:', err);
} finally {
setIsLoading(false);
}
}, [currentMarket, timeRange]);
useEffect(() => {
loadMarkets();
}, [loadMarkets]);
useEffect(() => {
loadData();
}, [loadData]);
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="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6 sm:mb-8">
<div>
<h1 className="text-xl sm:text-2xl font-semibold text-primary mb-1 sm:mb-2">Финансы</h1>
<p className="text-sm text-secondary">Котировки и аналитика рынков</p>
</div>
<button
onClick={loadData}
disabled={isLoading}
className="flex items-center gap-2 px-4 py-2.5 text-sm text-secondary hover:text-primary bg-surface/40 border border-border/50 hover:border-accent/30 rounded-xl transition-all w-fit"
>
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
<span className="hidden sm:inline">Обновить</span>
</button>
</div>
{/* Market Tabs */}
<Tabs.Root value={currentMarket} onValueChange={setCurrentMarket}>
<Tabs.List className="flex gap-2 mb-4 sm:mb-6 overflow-x-auto pb-1 -mx-4 px-4 sm:mx-0 sm:px-0">
{markets.map((m) => (
<Tabs.Trigger
key={m.id}
value={m.id}
className="px-4 py-2.5 text-sm rounded-xl border transition-all whitespace-nowrap data-[state=active]:bg-accent/10 data-[state=active]:border-accent/30 data-[state=active]:text-primary bg-surface/30 border-border/30 text-muted hover:text-secondary hover:border-border"
>
{m.name}
</Tabs.Trigger>
))}
</Tabs.List>
</Tabs.Root>
{/* Time Range */}
<div className="flex gap-1 p-1.5 bg-elevated/40 border border-border/30 rounded-xl w-fit mb-6 sm:mb-8 overflow-x-auto">
{timeRanges.map((range) => (
<button
key={range.id}
onClick={() => setTimeRange(range.id)}
className={`px-3 py-1.5 text-xs rounded-lg transition-all whitespace-nowrap ${
timeRange === range.id
? 'bg-accent/15 text-accent border border-accent/30'
: 'text-muted hover:text-secondary'
}`}
>
{range.label}
</button>
))}
</div>
{/* Content */}
{isLoading ? (
<div className="flex flex-col items-center justify-center py-16 sm:py-20">
<Loader2 className="w-8 h-8 animate-spin text-accent/60" />
<p className="text-sm text-muted mt-4">Загрузка данных...</p>
</div>
) : (
<>
{moversData && (
<div className="grid gap-4 sm:gap-6">
<MoversSection
title="Лидеры роста"
stocks={moversData.gainers}
market={currentMarket}
icon={ArrowUpRight}
color="bg-success/10 text-success"
/>
<MoversSection
title="Лидеры падения"
stocks={moversData.losers}
market={currentMarket}
icon={ArrowDownRight}
color="bg-error/10 text-error"
/>
<MoversSection
title="Самые активные"
stocks={moversData.mostActive}
market={currentMarket}
icon={Activity}
color="bg-accent/10 text-accent"
/>
</div>
)}
{heatmapData && heatmapData.sectors && heatmapData.sectors.length > 0 && (
<div className="mt-6 sm:mt-8">
<div className="flex items-center gap-3 mb-3 sm:mb-4">
<div className="w-8 h-8 sm:w-9 sm:h-9 rounded-xl bg-surface/60 flex items-center justify-center">
<BarChart3 className="w-4 h-4 text-secondary" />
</div>
<h3 className="text-sm font-medium text-primary">По секторам</h3>
</div>
<div className="space-y-3 sm:space-y-4">
{heatmapData.sectors.map((sector) => (
<div key={sector.name} className="bg-elevated/40 border border-border/40 rounded-xl p-3 sm:p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-primary">{sector.name}</span>
<span className={`text-xs font-mono ${sector.change >= 0 ? 'text-success' : 'text-error'}`}>
{formatChange(sector.change)}
</span>
</div>
<div className="space-y-1">
{sector.tickers.slice(0, 3).map((stock, i) => (
<StockRow key={stock.symbol} stock={stock} market={currentMarket} delay={i * 0.02} />
))}
</div>
</div>
))}
</div>
</div>
)}
{!moversData && !heatmapData && (
<div className="text-center py-16 sm:py-20">
<BarChart3 className="w-12 h-12 mx-auto mb-4 text-muted" />
<p className="text-secondary">Данные недоступны для выбранного рынка</p>
</div>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,246 @@
'use client';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { motion } from 'framer-motion';
import { Clock, Search, ChevronRight, Trash2, RefreshCw, Loader2, Share2, MessageSquare, Check } from 'lucide-react';
import { fetchThreads, deleteThread, shareThread } from '@/lib/api';
import type { Thread } from '@/lib/types';
function formatTime(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
}
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diff = now.getTime() - date.getTime();
const dayMs = 24 * 60 * 60 * 1000;
if (diff < dayMs && date.getDate() === now.getDate()) {
return 'Сегодня';
}
if (diff < 2 * dayMs && date.getDate() === now.getDate() - 1) {
return 'Вчера';
}
if (diff < 7 * dayMs) {
return date.toLocaleDateString('ru-RU', { weekday: 'long' });
}
return date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' });
}
export default function HistoryPage() {
const router = useRouter();
const [threads, setThreads] = useState<Thread[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [search, setSearch] = useState('');
const [deletingId, setDeletingId] = useState<string | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);
const load = useCallback(async () => {
setIsLoading(true);
try {
const data = await fetchThreads(100, 0);
setThreads(data);
} catch (err) {
console.error('Failed to load threads:', err);
setThreads([]);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
const handleDelete = async (id: string, e: React.MouseEvent) => {
e.stopPropagation();
if (deletingId) return;
setDeletingId(id);
try {
await deleteThread(id);
setThreads((prev) => prev.filter((t) => t.id !== id));
} catch (err) {
console.error('Failed to delete thread:', err);
} finally {
setDeletingId(null);
}
};
const handleShare = async (id: string, e: React.MouseEvent) => {
e.stopPropagation();
try {
const result = await shareThread(id);
const url = `${window.location.origin}${result.shareUrl}`;
await navigator.clipboard.writeText(url);
setCopiedId(id);
setTimeout(() => setCopiedId(null), 2000);
} catch (err) {
console.error('Failed to share thread:', err);
}
};
const handleClearAll = async () => {
if (!confirm('Удалить всю историю? Это действие нельзя отменить.')) return;
setIsLoading(true);
try {
await Promise.all(threads.map((t) => deleteThread(t.id)));
setThreads([]);
} catch (err) {
console.error('Failed to clear history:', err);
} finally {
setIsLoading(false);
}
};
const filtered = useMemo(() => {
if (!search.trim()) return threads;
const q = search.toLowerCase();
return threads.filter((t) => t.title.toLowerCase().includes(q));
}, [threads, search]);
const grouped = useMemo(() => {
return filtered.reduce((acc, thread) => {
const date = formatDate(thread.createdAt);
if (!acc[date]) acc[date] = [];
acc[date].push(thread);
return acc;
}, {} as Record<string, Thread[]>);
}, [filtered]);
const openThread = (id: string) => {
router.push(`/?thread=${id}`);
};
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="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6 sm:mb-8">
<div>
<h1 className="text-xl sm:text-2xl font-semibold text-primary mb-1 sm:mb-2">История</h1>
<p className="text-sm text-secondary">Ваши предыдущие поисковые сессии</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={load}
disabled={isLoading}
className="flex items-center gap-2 px-3 py-2.5 text-sm text-secondary hover:text-primary bg-surface/40 border border-border/50 hover:border-accent/30 rounded-xl transition-all"
>
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
</button>
{threads.length > 0 && (
<button
onClick={handleClearAll}
className="flex items-center gap-2 px-3 py-2.5 text-sm text-error hover:text-error bg-error/5 border border-error/20 hover:border-error/30 rounded-xl transition-all"
>
<Trash2 className="w-4 h-4" />
<span className="hidden sm:inline">Очистить</span>
</button>
)}
</div>
</div>
{/* Search */}
<div className="relative mb-6">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-muted" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Поиск в истории..."
className="w-full pl-11 pr-4 py-3 text-sm bg-elevated/40 border border-border/50 rounded-xl text-primary placeholder:text-muted focus:outline-none focus:border-accent/40 transition-colors"
/>
</div>
{/* Content */}
{isLoading ? (
<div className="flex flex-col items-center justify-center py-16 sm:py-20">
<Loader2 className="w-8 h-8 animate-spin text-accent/60" />
<p className="text-sm text-muted mt-4">Загрузка истории...</p>
</div>
) : Object.keys(grouped).length > 0 ? (
<div className="space-y-6 sm:space-y-8">
{Object.entries(grouped).map(([date, dateThreads]) => (
<div key={date}>
<h2 className="text-xs font-semibold text-muted uppercase tracking-wider mb-3 pl-1">
{date}
</h2>
<div className="space-y-2">
{dateThreads.map((thread, i) => (
<motion.div
key={thread.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.03 }}
onClick={() => openThread(thread.id)}
className="group flex items-center gap-3 sm:gap-4 p-3 sm:p-4 rounded-xl bg-elevated/40 border border-border/40 hover:bg-elevated/60 hover:border-accent/25 cursor-pointer transition-all duration-200"
>
<div className="w-9 h-9 sm:w-10 sm:h-10 rounded-xl bg-surface/60 flex items-center justify-center flex-shrink-0">
<MessageSquare className="w-4 h-4 text-secondary" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-primary truncate group-hover:text-accent transition-colors">
{thread.title}
</p>
<div className="flex items-center gap-2 mt-1">
{thread.focusMode && thread.focusMode !== 'all' && (
<span className="text-xs text-muted bg-surface/60 px-2 py-0.5 rounded capitalize">
{thread.focusMode}
</span>
)}
<span className="text-xs text-faint">
{formatTime(thread.createdAt)}
</span>
</div>
</div>
<div className="flex items-center gap-1 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => handleShare(thread.id, e)}
className="p-2 rounded-lg hover:bg-surface/60 transition-colors"
title="Поделиться"
>
{copiedId === thread.id ? (
<Check className="w-4 h-4 text-success" />
) : (
<Share2 className="w-4 h-4 text-muted hover:text-secondary" />
)}
</button>
<button
onClick={(e) => handleDelete(thread.id, e)}
disabled={deletingId === thread.id}
className="p-2 rounded-lg hover:bg-error/10 transition-colors"
title="Удалить"
>
{deletingId === thread.id ? (
<Loader2 className="w-4 h-4 animate-spin text-error" />
) : (
<Trash2 className="w-4 h-4 text-error/60 hover:text-error" />
)}
</button>
</div>
<ChevronRight className="w-4 h-4 text-faint group-hover:text-secondary transition-colors hidden sm:block" />
</motion.div>
))}
</div>
</div>
))}
</div>
) : (
<div className="text-center py-16 sm:py-20">
<Clock className="w-12 h-12 mx-auto mb-4 text-muted" />
<p className="text-secondary mb-1">
{search ? 'Ничего не найдено' : 'История пуста'}
</p>
<p className="text-sm text-muted">
{search ? 'Попробуйте изменить запрос' : 'Начните новый поиск'}
</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,86 @@
'use client';
import { useState, useEffect } from 'react';
import { usePathname } from 'next/navigation';
import { motion, AnimatePresence } from 'framer-motion';
import { Menu, X } from 'lucide-react';
import { Sidebar } from '@/components/Sidebar';
export default function MainLayout({ children }: { children: React.ReactNode }) {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const pathname = usePathname();
useEffect(() => {
const checkMobile = () => {
const mobile = window.innerWidth < 768;
setIsMobile(mobile);
if (!mobile) {
setSidebarOpen(false);
}
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
useEffect(() => {
setSidebarOpen(false);
}, [pathname]);
return (
<div className="flex h-[100dvh] bg-gradient-main overflow-hidden">
{/* Mobile Header */}
{isMobile && (
<div className="fixed top-0 left-0 right-0 z-40 h-14 bg-base/95 backdrop-blur-lg border-b border-border/50 flex items-center justify-between px-4">
<button
onClick={() => setSidebarOpen(true)}
className="w-10 h-10 flex items-center justify-center rounded-xl text-secondary hover:text-primary hover:bg-surface/50 transition-all"
>
<Menu className="w-5 h-5" />
</button>
<span className="text-sm font-semibold text-primary">GooSeek</span>
<div className="w-10" />
</div>
)}
{/* Desktop Sidebar */}
{!isMobile && <Sidebar />}
{/* Mobile Sidebar Overlay */}
<AnimatePresence>
{isMobile && sidebarOpen && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setSidebarOpen(false)}
className="fixed inset-0 z-40 bg-base/80 backdrop-blur-sm"
/>
<motion.div
initial={{ x: -280 }}
animate={{ x: 0 }}
exit={{ x: -280 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
className="fixed left-0 top-0 bottom-0 z-50 w-[280px]"
>
<Sidebar onClose={() => setSidebarOpen(false)} />
</motion.div>
</>
)}
</AnimatePresence>
{/* Main Content */}
<main
className={`
flex-1 min-w-0 overflow-auto
${isMobile ? 'pt-14' : ''}
`}
>
{children}
</main>
</div>
);
}

View File

@@ -0,0 +1,201 @@
'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 focus:border-accent/40 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
? 'bg-accent/10 border-accent/30 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-accent' : ''}`} />
<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 focus:border-accent/40 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
? 'bg-accent/10 border-accent/30 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-accent' : ''}`} />
<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
? 'bg-accent/10 border-accent/30 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-accent' : ''}`} />
<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>
);
}

View File

@@ -0,0 +1,218 @@
'use client';
import { useState, useEffect, useCallback, useMemo } from 'react';
import Link from 'next/link';
import { motion } from 'framer-motion';
import { BookOpen, Play, CheckCircle2, Clock, Plus, Trash2, Loader2, RefreshCw, GraduationCap } from 'lucide-react';
import { fetchLessons, deleteLesson } from '@/lib/api';
import type { Lesson } from '@/lib/types';
function formatTime(time: string): string {
return time || '~30 мин';
}
function getProgressPercent(lesson: Lesson): number {
if (!lesson.progress) return 0;
const completed = lesson.progress.completedSteps?.length || 0;
return Math.round((completed / lesson.stepsCount) * 100);
}
export default function LearningPage() {
const [lessons, setLessons] = useState<Lesson[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [deletingId, setDeletingId] = useState<string | null>(null);
const load = useCallback(async () => {
setIsLoading(true);
try {
const data = await fetchLessons(50, 0);
setLessons(data.lessons || []);
} catch (err) {
console.error('Failed to load lessons:', err);
setLessons([]);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
const handleDelete = async (id: string, e: React.MouseEvent) => {
e.stopPropagation();
if (deletingId) return;
setDeletingId(id);
try {
await deleteLesson(id);
setLessons((prev) => prev.filter((l) => l.id !== id));
} catch (err) {
console.error('Failed to delete lesson:', err);
} finally {
setDeletingId(null);
}
};
const stats = useMemo(() => {
const completed = lessons.filter((l) => getProgressPercent(l) === 100).length;
const inProgress = lessons.filter((l) => {
const pct = getProgressPercent(l);
return pct > 0 && pct < 100;
}).length;
return { completed, inProgress, total: lessons.length };
}, [lessons]);
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="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6 sm:mb-8">
<div>
<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>
<div className="flex items-center gap-2">
<button
onClick={load}
disabled={isLoading}
className="flex items-center gap-2 px-3 py-2.5 text-sm text-secondary hover:text-primary bg-surface/40 border border-border/50 hover:border-accent/30 rounded-xl transition-all"
>
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
</button>
<Link
href="/learning/new"
className="flex items-center gap-2 px-4 py-2.5 text-sm btn-gradient"
>
<Plus className="w-4 h-4 btn-gradient-text" />
<span className="hidden sm:inline btn-gradient-text">Новый урок</span>
</Link>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-3 sm:gap-4 mb-6 sm:mb-8">
<div className="p-3 sm:p-4 bg-success/5 border border-success/20 rounded-xl text-center">
<p className="text-xl sm:text-2xl font-semibold text-success">{stats.completed}</p>
<p className="text-xs text-muted mt-1">Завершено</p>
</div>
<div className="p-3 sm:p-4 bg-accent/5 border border-accent/20 rounded-xl text-center">
<p className="text-xl sm:text-2xl font-semibold text-accent">{stats.inProgress}</p>
<p className="text-xs text-muted mt-1">В процессе</p>
</div>
<div className="p-3 sm:p-4 bg-surface/40 border border-border/40 rounded-xl text-center">
<p className="text-xl sm:text-2xl font-semibold text-primary">{stats.total}</p>
<p className="text-xs text-muted mt-1">Всего</p>
</div>
</div>
{/* Content */}
{isLoading ? (
<div className="flex flex-col items-center justify-center py-16 sm:py-20">
<Loader2 className="w-8 h-8 animate-spin text-accent/60" />
<p className="text-sm text-muted mt-4">Загрузка уроков...</p>
</div>
) : lessons.length > 0 ? (
<div className="space-y-3">
{lessons.map((lesson, i) => {
const pct = getProgressPercent(lesson);
const done = pct === 100;
const inProgress = pct > 0 && pct < 100;
return (
<motion.div
key={lesson.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.05 }}
className="group p-3 sm:p-4 bg-elevated/40 border border-border/40 rounded-xl hover:bg-elevated/60 hover:border-accent/25 cursor-pointer transition-all duration-200"
>
<div className="flex items-start gap-3 sm:gap-4">
<div
className={`w-10 h-10 sm:w-12 sm:h-12 rounded-xl flex items-center justify-center flex-shrink-0 ${
done
? 'bg-success/10 border border-success/25'
: inProgress
? 'bg-accent/10 border border-accent/25'
: 'bg-surface/60 border border-border/50'
}`}
>
{done ? (
<CheckCircle2 className="w-4 h-4 sm:w-5 sm:h-5 text-success" />
) : inProgress ? (
<Play className="w-4 h-4 sm:w-5 sm:h-5 text-accent" />
) : (
<BookOpen className="w-4 h-4 sm:w-5 sm:h-5 text-secondary" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium text-primary group-hover:text-accent transition-colors truncate">
{lesson.title || lesson.topic}
</p>
<button
onClick={(e) => handleDelete(lesson.id, e)}
disabled={deletingId === lesson.id}
className="p-1.5 hover:bg-error/10 rounded-lg transition-all flex-shrink-0 sm:opacity-0 sm:group-hover:opacity-100"
>
{deletingId === lesson.id ? (
<Loader2 className="w-4 h-4 animate-spin text-error" />
) : (
<Trash2 className="w-4 h-4 text-error/60 hover:text-error" />
)}
</button>
</div>
<div className="flex flex-wrap items-center gap-2 sm:gap-3 mt-2">
<span className="text-xs text-muted bg-surface/60 px-2 py-0.5 rounded capitalize">
{lesson.difficulty}
</span>
<div className="flex items-center gap-1 text-xs text-faint">
<Clock className="w-3 h-3" />
{formatTime(lesson.estimatedTime)}
</div>
<div className="flex items-center gap-1 text-xs text-faint">
<GraduationCap className="w-3 h-3" />
{lesson.stepsCount} шагов
</div>
</div>
<div className="flex items-center gap-3 mt-3">
<div className="flex-1 h-1.5 bg-surface/60 rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${pct}%` }}
transition={{ duration: 0.5, ease: 'easeOut' }}
className={`h-full rounded-full ${
done ? 'bg-success' : 'bg-accent'
}`}
/>
</div>
<span className="text-xs text-faint tabular-nums">
{lesson.progress?.completedSteps?.length || 0}/{lesson.stepsCount}
</span>
</div>
</div>
</div>
</motion.div>
);
})}
</div>
) : (
<div className="text-center py-16 sm:py-20">
<GraduationCap className="w-12 h-12 mx-auto mb-4 text-muted" />
<p className="text-secondary mb-1">Пока нет уроков</p>
<p className="text-sm text-muted mb-6">
Создайте первый урок, чтобы начать обучение
</p>
<Link
href="/learning/new"
className="inline-flex items-center gap-2 px-4 py-2.5 text-sm btn-gradient"
>
<Plus className="w-4 h-4 btn-gradient-text" />
<span className="btn-gradient-text">Создать первый урок</span>
</Link>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,108 @@
'use client';
import { useRef, useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ChatInput } from '@/components/ChatInput';
import { ChatMessage } from '@/components/ChatMessage';
import { useChat } from '@/lib/hooks/useChat';
export default function HomePage() {
const { messages, isLoading, sendMessage, stopGeneration } = useChat();
const messagesEndRef = useRef<HTMLDivElement>(null);
const [showWelcome, setShowWelcome] = useState(true);
useEffect(() => {
if (messages.length > 0) {
setShowWelcome(false);
}
}, [messages.length]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
return (
<div className="flex flex-col h-full bg-gradient-main">
<AnimatePresence mode="wait">
{showWelcome ? (
<motion.div
key="welcome"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
className="flex-1 overflow-y-auto"
>
<div className="flex flex-col items-center justify-center min-h-full px-4 sm:px-6 py-8">
{/* Title */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1, duration: 0.4 }}
className="text-center mb-8 sm:mb-10"
>
<h1 className="text-2xl sm:text-4xl font-bold text-primary tracking-tight">
Что вы хотите{' '}
<span className="text-gradient">узнать?</span>
</h1>
</motion.div>
{/* Search Input */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2, duration: 0.4 }}
className="w-full max-w-2xl px-2"
>
<ChatInput
onSend={sendMessage}
onStop={stopGeneration}
isLoading={isLoading}
placeholder="Спросите что угодно..."
autoFocus
/>
</motion.div>
</div>
</motion.div>
) : (
<motion.div
key="chat"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className="flex flex-col h-full"
>
{/* Messages */}
<div className="flex-1 overflow-y-auto px-4 sm:px-6 py-6 sm:py-8">
<div className="max-w-3xl mx-auto space-y-6 sm:space-y-8">
{messages.map((message, i) => (
<motion.div
key={message.id}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.05 }}
>
<ChatMessage message={message} />
</motion.div>
))}
<div ref={messagesEndRef} />
</div>
</div>
{/* Sticky Input */}
<div className="sticky bottom-0 px-4 sm:px-6 pb-4 sm:pb-6 pt-4 bg-gradient-to-t from-base via-base/95 to-transparent">
<div className="max-w-3xl mx-auto">
<ChatInput
onSend={sendMessage}
onStop={stopGeneration}
isLoading={isLoading}
placeholder="Продолжить диалог..."
/>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,172 @@
'use client';
import { useState } from 'react';
import { motion } from 'framer-motion';
import { Shield, Zap, Scale, Sparkles, Download, Trash2, Bell, Eye, Languages } from 'lucide-react';
import * as Switch from '@radix-ui/react-switch';
export default function SettingsPage() {
const [mode, setMode] = useState('balanced');
const [saveHistory, setSaveHistory] = useState(true);
const [personalization, setPersonalization] = useState(true);
const [notifications, setNotifications] = useState(false);
const [language, setLanguage] = useState('ru');
return (
<div className="h-full overflow-y-auto">
<div className="max-w-xl 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">Персонализируйте ваш опыт</p>
</div>
<div className="space-y-6 sm:space-y-8">
{/* Default Mode */}
<Section title="Режим по умолчанию" icon={Zap}>
<div className="grid grid-cols-3 gap-2 sm:gap-3">
{[
{ id: 'speed', label: 'Быстрый', icon: Zap, desc: '~10 сек' },
{ id: 'balanced', label: 'Баланс', icon: Scale, desc: '~30 сек' },
{ id: 'quality', label: 'Качество', icon: Sparkles, desc: '~60 сек' },
].map((m) => (
<button
key={m.id}
onClick={() => setMode(m.id)}
className={`flex flex-col items-center gap-1 sm:gap-2 p-3 sm:p-4 rounded-xl border transition-all ${
mode === m.id
? 'bg-accent/10 border-accent/30 text-primary'
: 'bg-surface/30 border-border/30 text-muted hover:border-border hover:text-secondary'
}`}
>
<m.icon className={`w-4 h-4 sm:w-5 sm:h-5 ${mode === m.id ? 'text-accent' : ''}`} />
<span className="text-xs sm:text-sm font-medium">{m.label}</span>
<span className="text-[10px] sm:text-xs text-faint">{m.desc}</span>
</button>
))}
</div>
</Section>
{/* Privacy & Data */}
<Section title="Приватность" icon={Shield}>
<div className="space-y-3">
<ToggleRow
icon={Eye}
label="Сохранять историю"
description="Автоматическое сохранение сессий"
checked={saveHistory}
onChange={setSaveHistory}
/>
<ToggleRow
icon={Sparkles}
label="Персонализация"
description="Улучшение на основе истории"
checked={personalization}
onChange={setPersonalization}
/>
<ToggleRow
icon={Bell}
label="Уведомления"
description="Уведомления об обновлениях"
checked={notifications}
onChange={setNotifications}
/>
</div>
</Section>
{/* Language */}
<Section title="Язык интерфейса" icon={Languages}>
<div className="grid grid-cols-2 gap-2 sm:gap-3">
{[
{ id: 'ru', label: 'Русский', flag: '🇷🇺' },
{ id: 'en', label: 'English', flag: '🇺🇸' },
].map((lang) => (
<button
key={lang.id}
onClick={() => setLanguage(lang.id)}
className={`flex items-center gap-3 p-3 sm:p-4 rounded-xl border transition-all ${
language === lang.id
? 'bg-accent/10 border-accent/30 text-primary'
: 'bg-surface/30 border-border/30 text-muted hover:border-border hover:text-secondary'
}`}
>
<span className="text-lg">{lang.flag}</span>
<span className="text-sm font-medium">{lang.label}</span>
</button>
))}
</div>
</Section>
{/* Data Management */}
<Section title="Управление данными" icon={Download}>
<div className="flex flex-col sm:flex-row gap-3">
<button className="flex-1 flex items-center justify-center gap-2 px-4 py-3 text-sm bg-surface/40 border border-border/50 text-secondary rounded-xl hover:bg-surface/60 hover:border-border hover:text-primary transition-all">
<Download className="w-4 h-4" />
Экспорт данных
</button>
<button className="flex-1 flex items-center justify-center gap-2 px-4 py-3 text-sm bg-error/5 border border-error/20 text-error rounded-xl hover:bg-error/10 hover:border-error/30 transition-all">
<Trash2 className="w-4 h-4" />
Удалить всё
</button>
</div>
</Section>
{/* About */}
<div className="pt-6 border-t border-border/30">
<div className="text-center text-faint text-xs">
<p className="mb-1">GooSeek v1.0.0</p>
<p>AI-поиск нового поколения</p>
</div>
</div>
</div>
</div>
</div>
);
}
function Section({ title, icon: Icon, children }: { title: string; icon: React.ElementType; children: React.ReactNode }) {
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-3 sm:space-y-4"
>
<div className="flex items-center gap-2">
<Icon className="w-4 h-4 text-muted" />
<h2 className="text-xs font-semibold text-muted uppercase tracking-wider">
{title}
</h2>
</div>
{children}
</motion.div>
);
}
interface ToggleRowProps {
icon: React.ElementType;
label: string;
description: string;
checked: boolean;
onChange: (v: boolean) => void;
}
function ToggleRow({ icon: Icon, label, description, checked, onChange }: ToggleRowProps) {
return (
<div className="flex items-center gap-3 sm:gap-4 p-3 sm:p-4 bg-elevated/40 border border-border/40 rounded-xl">
<div className="w-9 h-9 sm:w-10 sm:h-10 rounded-xl bg-surface/60 flex items-center justify-center flex-shrink-0">
<Icon className="w-4 h-4 text-secondary" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-primary truncate">{label}</p>
<p className="text-xs text-muted mt-0.5 truncate">{description}</p>
</div>
<Switch.Root
checked={checked}
onCheckedChange={onChange}
className="w-10 h-[22px] sm:w-11 sm:h-6 bg-surface/80 rounded-full relative transition-colors data-[state=checked]:bg-accent/20 border border-border data-[state=checked]:border-accent/30 flex-shrink-0"
>
<Switch.Thumb className="block w-[18px] h-[18px] sm:w-5 sm:h-5 bg-secondary rounded-full transition-transform translate-x-0.5 data-[state=checked]:translate-x-[18px] sm:data-[state=checked]:translate-x-[22px] data-[state=checked]:bg-accent" />
</Switch.Root>
</div>
);
}

View File

@@ -0,0 +1,248 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useRouter, useParams } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeft, Loader2, Globe, BookOpen, Code2, Newspaper, TrendingUp, Youtube, Trash2 } from 'lucide-react';
import { fetchSpaces, updateSpace, deleteSpace } from '@/lib/api';
import type { Space, FocusMode } from '@/lib/types';
const focusModes: { value: FocusMode; label: string; icon: React.ElementType }[] = [
{ value: 'all', label: 'Все источники', icon: Globe },
{ value: 'academic', label: 'Академический', icon: BookOpen },
{ value: 'code', label: 'Код', icon: Code2 },
{ value: 'news', label: 'Новости', icon: Newspaper },
{ value: 'finance', label: 'Финансы', icon: TrendingUp },
{ value: 'youtube', label: 'YouTube', icon: Youtube },
];
export default function EditSpacePage() {
const router = useRouter();
const params = useParams();
const spaceId = params.id as string;
const [space, setSpace] = useState<Space | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [formData, setFormData] = useState({
name: '',
description: '',
instructions: '',
focusMode: 'all' as FocusMode,
});
const loadSpace = useCallback(async () => {
setIsLoading(true);
try {
const spaces = await fetchSpaces();
const found = spaces.find((s: Space) => s.id === spaceId);
if (found) {
setSpace(found);
setFormData({
name: found.name,
description: found.description || '',
instructions: found.instructions || '',
focusMode: found.focusMode || 'all',
});
} else {
setError('Пространство не найдено');
}
} catch (err) {
console.error('Failed to load space:', err);
setError('Не удалось загрузить пространство');
} finally {
setIsLoading(false);
}
}, [spaceId]);
useEffect(() => {
loadSpace();
}, [loadSpace]);
const handleUpdate = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name.trim() || isSubmitting) return;
setIsSubmitting(true);
setError(null);
try {
await updateSpace(spaceId, {
name: formData.name.trim(),
description: formData.description.trim() || undefined,
instructions: formData.instructions.trim() || undefined,
focusMode: formData.focusMode,
});
router.push('/spaces');
} catch (err) {
console.error('Failed to update space:', err);
setError('Не удалось обновить пространство. Попробуйте позже.');
} finally {
setIsSubmitting(false);
}
};
const handleDelete = async () => {
if (isDeleting) return;
if (!confirm('Удалить пространство? Все связанные треды останутся в истории.')) return;
setIsDeleting(true);
try {
await deleteSpace(spaceId);
router.push('/spaces');
} catch (err) {
console.error('Failed to delete space:', err);
setError('Не удалось удалить пространство');
setIsDeleting(false);
}
};
if (isLoading) {
return (
<div className="h-full flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-accent/60" />
</div>
);
}
if (!space) {
return (
<div className="h-full flex flex-col items-center justify-center gap-4">
<p className="text-secondary">{error || 'Пространство не найдено'}</p>
<Link
href="/spaces"
className="px-4 py-2.5 text-sm btn-gradient"
>
<span className="btn-gradient-text">Вернуться к списку</span>
</Link>
</div>
);
}
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="/spaces"
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 className="flex-1 min-w-0">
<h1 className="text-xl sm:text-2xl font-semibold text-primary truncate">Редактировать</h1>
<p className="text-sm text-secondary mt-0.5 truncate">{space.name}</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={handleUpdate} 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.name}
onChange={(e) => setFormData((f) => ({ ...f, name: e.target.value }))}
placeholder="Название пространства"
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 focus:border-accent/40 transition-colors"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary mb-2">
Описание
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData((f) => ({ ...f, description: e.target.value }))}
placeholder="Краткое описание"
rows={2}
className="w-full px-4 py-3 text-sm bg-elevated/60 border border-border rounded-xl text-primary placeholder:text-muted resize-none focus:outline-none focus:border-accent/40 transition-colors"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary mb-2">
AI инструкции
</label>
<textarea
value={formData.instructions}
onChange={(e) => setFormData((f) => ({ ...f, instructions: e.target.value }))}
placeholder="Специальные инструкции для AI"
rows={3}
className="w-full px-4 py-3 text-sm bg-elevated/60 border border-border rounded-xl text-primary placeholder:text-muted resize-none focus:outline-none focus:border-accent/40 transition-colors"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary mb-3">
Режим фокуса
</label>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{focusModes.map((mode) => (
<button
key={mode.value}
type="button"
onClick={() => setFormData((f) => ({ ...f, focusMode: mode.value }))}
className={`flex flex-col items-center gap-2 p-3 rounded-xl border transition-all ${
formData.focusMode === mode.value
? 'bg-accent/10 border-accent/30 text-primary'
: 'bg-elevated/40 border-border/50 text-muted hover:border-border hover:text-secondary'
}`}
>
<mode.icon className={`w-4 h-4 ${formData.focusMode === mode.value ? 'text-accent' : ''}`} />
<span className="text-xs text-center">{mode.label}</span>
</button>
))}
</div>
</div>
{/* Actions */}
<div className="flex flex-col gap-3 pt-4">
<div className="flex flex-col-reverse sm:flex-row gap-3">
<Link
href="/spaces"
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.name.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>
<button
type="button"
onClick={handleDelete}
disabled={isDeleting}
className="flex items-center justify-center gap-2 px-4 py-3 text-sm text-error bg-error/5 border border-error/20 rounded-xl hover:bg-error/10 transition-all"
>
{isDeleting ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
Удалить пространство
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,162 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeft, Loader2, Globe, BookOpen, Code2, Newspaper, TrendingUp, Youtube } from 'lucide-react';
import { createSpace } from '@/lib/api';
import type { FocusMode } from '@/lib/types';
const focusModes: { value: FocusMode; label: string; icon: React.ElementType }[] = [
{ value: 'all', label: 'Все источники', icon: Globe },
{ value: 'academic', label: 'Академический', icon: BookOpen },
{ value: 'code', label: 'Код', icon: Code2 },
{ value: 'news', label: 'Новости', icon: Newspaper },
{ value: 'finance', label: 'Финансы', icon: TrendingUp },
{ value: 'youtube', label: 'YouTube', icon: Youtube },
];
export default function NewSpacePage() {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [formData, setFormData] = useState({
name: '',
description: '',
instructions: '',
focusMode: 'all' as FocusMode,
});
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name.trim() || isSubmitting) return;
setIsSubmitting(true);
setError(null);
try {
await createSpace({
name: formData.name.trim(),
description: formData.description.trim() || undefined,
instructions: formData.instructions.trim() || undefined,
focusMode: formData.focusMode,
});
router.push('/spaces');
} catch (err) {
console.error('Failed to create space:', 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="/spaces"
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">Организуйте исследования по темам</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.name}
onChange={(e) => setFormData((f) => ({ ...f, name: e.target.value }))}
placeholder="Например: Исследование рынка"
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 focus:border-accent/40 transition-colors"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary mb-2">
Описание
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData((f) => ({ ...f, description: e.target.value }))}
placeholder="Краткое описание пространства"
rows={2}
className="w-full px-4 py-3 text-sm bg-elevated/60 border border-border rounded-xl text-primary placeholder:text-muted resize-none focus:outline-none focus:border-accent/40 transition-colors"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary mb-2">
AI инструкции
</label>
<textarea
value={formData.instructions}
onChange={(e) => setFormData((f) => ({ ...f, instructions: e.target.value }))}
placeholder="Специальные инструкции для AI в этом пространстве"
rows={3}
className="w-full px-4 py-3 text-sm bg-elevated/60 border border-border rounded-xl text-primary placeholder:text-muted resize-none focus:outline-none focus:border-accent/40 transition-colors"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary mb-3">
Режим фокуса
</label>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{focusModes.map((mode) => (
<button
key={mode.value}
type="button"
onClick={() => setFormData((f) => ({ ...f, focusMode: mode.value }))}
className={`flex flex-col items-center gap-2 p-3 rounded-xl border transition-all ${
formData.focusMode === mode.value
? 'bg-accent/10 border-accent/30 text-primary'
: 'bg-elevated/40 border-border/50 text-muted hover:border-border hover:text-secondary'
}`}
>
<mode.icon className={`w-4 h-4 ${formData.focusMode === mode.value ? 'text-accent' : ''}`} />
<span className="text-xs text-center">{mode.label}</span>
</button>
))}
</div>
</div>
{/* Actions */}
<div className="flex flex-col-reverse sm:flex-row gap-3 pt-4">
<Link
href="/spaces"
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.name.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>
);
}

View File

@@ -0,0 +1,205 @@
'use client';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { motion } from 'framer-motion';
import { FolderOpen, Plus, MoreHorizontal, Search, Trash2, Loader2, Settings, RefreshCw, Globe, BookOpen, Code2, TrendingUp, Newspaper, Youtube } from 'lucide-react';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { fetchSpaces, deleteSpace } from '@/lib/api';
import type { Space, FocusMode } from '@/lib/types';
const focusModes: { value: FocusMode; label: string; icon: React.ElementType }[] = [
{ value: 'all', label: 'Все источники', icon: Globe },
{ value: 'academic', label: 'Академический', icon: BookOpen },
{ value: 'code', label: 'Код', icon: Code2 },
{ value: 'news', label: 'Новости', icon: Newspaper },
{ value: 'finance', label: 'Финансы', icon: TrendingUp },
{ value: 'youtube', label: 'YouTube', icon: Youtube },
];
export default function SpacesPage() {
const router = useRouter();
const [spaces, setSpaces] = useState<Space[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [search, setSearch] = useState('');
const load = useCallback(async () => {
setIsLoading(true);
try {
const data = await fetchSpaces();
setSpaces(data);
} catch (err) {
console.error('Failed to load spaces:', err);
setSpaces([]);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
const handleDelete = async (id: string) => {
if (!confirm('Удалить пространство? Все связанные треды останутся в истории.')) return;
try {
await deleteSpace(id);
setSpaces((prev) => prev.filter((s) => s.id !== id));
} catch (err) {
console.error('Failed to delete space:', err);
}
};
const filtered = useMemo(() => {
if (!search.trim()) return spaces;
const q = search.toLowerCase();
return spaces.filter(
(s) => s.name.toLowerCase().includes(q) || s.description?.toLowerCase().includes(q)
);
}, [spaces, search]);
const openSpace = (id: string) => {
router.push(`/?space=${id}`);
};
const getFocusModeIcon = (mode: FocusMode) => {
const found = focusModes.find((m) => m.value === mode);
return found?.icon || Globe;
};
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="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6 sm:mb-8">
<div>
<h1 className="text-xl sm:text-2xl font-semibold text-primary mb-1 sm:mb-2">Пространства</h1>
<p className="text-sm text-secondary">Организуйте исследования по темам</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={load}
disabled={isLoading}
className="flex items-center gap-2 px-3 py-2.5 text-sm text-secondary hover:text-primary bg-surface/40 border border-border/50 hover:border-accent/30 rounded-xl transition-all"
>
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
</button>
<Link
href="/spaces/new"
className="flex items-center gap-2 px-4 py-2.5 text-sm btn-gradient"
>
<Plus className="w-4 h-4 btn-gradient-text" />
<span className="hidden sm:inline btn-gradient-text">Создать</span>
</Link>
</div>
</div>
{/* Search */}
<div className="relative mb-6">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-muted" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Поиск пространств..."
className="w-full pl-11 pr-4 py-3 text-sm bg-elevated/40 border border-border/50 rounded-xl text-primary placeholder:text-muted focus:outline-none focus:border-accent/40 transition-colors"
/>
</div>
{/* Content */}
{isLoading ? (
<div className="flex flex-col items-center justify-center py-16 sm:py-20">
<Loader2 className="w-8 h-8 animate-spin text-accent/60" />
<p className="text-sm text-muted mt-4">Загрузка пространств...</p>
</div>
) : filtered.length > 0 ? (
<div className="grid gap-3">
{filtered.map((space, i) => {
const FocusIcon = getFocusModeIcon(space.focusMode || 'all');
return (
<motion.div
key={space.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.05 }}
onClick={() => openSpace(space.id)}
className="group flex items-center gap-3 sm:gap-4 p-3 sm:p-4 bg-elevated/40 border border-border/40 rounded-xl hover:bg-elevated/60 hover:border-accent/25 cursor-pointer transition-all duration-200"
>
<div className="w-10 h-10 sm:w-12 sm:h-12 rounded-xl bg-accent/10 border border-accent/20 flex items-center justify-center flex-shrink-0">
<FolderOpen className="w-4 h-4 sm:w-5 sm:h-5 text-accent/70" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-primary group-hover:text-accent transition-colors truncate">
{space.name}
</h3>
<div className="flex items-center gap-2 mt-1">
<FocusIcon className="w-3 h-3 text-muted flex-shrink-0" />
<span className="text-xs text-muted truncate">
{space.description || focusModes.find((m) => m.value === space.focusMode)?.label || 'Все источники'}
</span>
</div>
</div>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
onClick={(e) => e.stopPropagation()}
className="p-2 hover:bg-surface/60 rounded-lg transition-all sm:opacity-0 sm:group-hover:opacity-100"
>
<MoreHorizontal className="w-4 h-4 text-secondary" />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="min-w-[160px] bg-surface/95 backdrop-blur-xl border border-border rounded-xl p-1.5 shadow-dropdown z-50"
sideOffset={5}
>
<DropdownMenu.Item asChild>
<Link
href={`/spaces/${space.id}/edit`}
onClick={(e) => e.stopPropagation()}
className="flex items-center gap-2 px-3 py-2.5 text-sm text-secondary rounded-lg cursor-pointer hover:bg-elevated/80 hover:text-primary outline-none transition-colors"
>
<Settings className="w-4 h-4" />
Редактировать
</Link>
</DropdownMenu.Item>
<DropdownMenu.Item
onClick={(e) => {
e.stopPropagation();
handleDelete(space.id);
}}
className="flex items-center gap-2 px-3 py-2.5 text-sm text-error rounded-lg cursor-pointer hover:bg-error/10 outline-none transition-colors"
>
<Trash2 className="w-4 h-4" />
Удалить
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</motion.div>
);
})}
</div>
) : (
<div className="text-center py-16 sm:py-20">
<FolderOpen className="w-12 h-12 mx-auto mb-4 text-muted" />
<p className="text-secondary mb-1">
{search ? 'Ничего не найдено' : 'Нет пространств'}
</p>
<p className="text-sm text-muted mb-6">
Создайте пространство для организации исследований
</p>
<Link
href="/spaces/new"
className="inline-flex items-center gap-2 px-4 py-2.5 text-sm btn-gradient"
>
<Plus className="w-4 h-4 btn-gradient-text" />
<span className="btn-gradient-text">Создать пространство</span>
</Link>
</div>
)}
</div>
</div>
);
}