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:
752
backend/webui/src/app/(main)/computer/page.tsx
Normal file
752
backend/webui/src/app/(main)/computer/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
153
backend/webui/src/app/(main)/discover/page.tsx
Normal file
153
backend/webui/src/app/(main)/discover/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
265
backend/webui/src/app/(main)/finance/page.tsx
Normal file
265
backend/webui/src/app/(main)/finance/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
246
backend/webui/src/app/(main)/history/page.tsx
Normal file
246
backend/webui/src/app/(main)/history/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
backend/webui/src/app/(main)/layout.tsx
Normal file
86
backend/webui/src/app/(main)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
201
backend/webui/src/app/(main)/learning/new/page.tsx
Normal file
201
backend/webui/src/app/(main)/learning/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
218
backend/webui/src/app/(main)/learning/page.tsx
Normal file
218
backend/webui/src/app/(main)/learning/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
backend/webui/src/app/(main)/page.tsx
Normal file
108
backend/webui/src/app/(main)/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
172
backend/webui/src/app/(main)/settings/page.tsx
Normal file
172
backend/webui/src/app/(main)/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
248
backend/webui/src/app/(main)/spaces/[id]/edit/page.tsx
Normal file
248
backend/webui/src/app/(main)/spaces/[id]/edit/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
162
backend/webui/src/app/(main)/spaces/new/page.tsx
Normal file
162
backend/webui/src/app/(main)/spaces/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
205
backend/webui/src/app/(main)/spaces/page.tsx
Normal file
205
backend/webui/src/app/(main)/spaces/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user