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

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

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

View File

@@ -0,0 +1,7 @@
# GooSeek WebUI Configuration
# API Gateway URL (internal Docker network)
API_URL=http://api-gateway:3015
# Public API URL (for browser requests)
NEXT_PUBLIC_API_URL=

44
backend/webui/Dockerfile Normal file
View File

@@ -0,0 +1,44 @@
FROM node:20-alpine AS base
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --only=production
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
RUN mkdir .next
RUN chown nextjs:nodejs .next
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

5
backend/webui/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

View File

@@ -0,0 +1,18 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
reactStrictMode: true,
env: {
API_URL: process.env.API_URL || 'http://api-gateway:3015',
},
async rewrites() {
return [
{
source: '/api/:path*',
destination: `${process.env.API_URL || 'http://api-gateway:3015'}/api/:path*`,
},
];
},
};
export default nextConfig;

4408
backend/webui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
{
"name": "gooseek-webui",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",
"build": "next build",
"start": "next start -p 3000",
"lint": "next lint"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"clsx": "^2.1.1",
"framer-motion": "^12.34.3",
"lucide-react": "^0.454.0",
"next": "^14.2.26",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.1",
"tailwind-merge": "^2.5.4"
},
"devDependencies": {
"@types/node": "^22.9.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,12 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="40" height="40" rx="10" fill="url(#gradient)"/>
<path d="M20 10L26 16L20 22L14 16L20 10Z" fill="#0a1929"/>
<path d="M14 20L20 26L26 20L20 14L14 20Z" fill="#0a1929" opacity="0.7"/>
<path d="M20 22L26 28L20 34L14 28L20 22Z" fill="#0a1929" opacity="0.5"/>
<defs>
<linearGradient id="gradient" x1="0" y1="0" x2="40" y2="40" gradientUnits="userSpaceOnUse">
<stop stop-color="#d4a373"/>
<stop offset="1" stop-color="#cd7f32"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 595 B

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,201 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeft, Loader2, Target, BookOpen, Brain, Sparkles, Code2, CheckCircle2 } from 'lucide-react';
import { createLesson } from '@/lib/api';
const difficulties = [
{ value: 'beginner', label: 'Начинающий', icon: Target },
{ value: 'intermediate', label: 'Средний', icon: BookOpen },
{ value: 'advanced', label: 'Продвинутый', icon: Brain },
{ value: 'expert', label: 'Эксперт', icon: Sparkles },
];
const modes = [
{ value: 'explain', label: 'Объяснение' },
{ value: 'guided', label: 'С наставником' },
{ value: 'interactive', label: 'Интерактив' },
{ value: 'practice', label: 'Практика' },
{ value: 'quiz', label: 'Тест' },
];
export default function NewLessonPage() {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [formData, setFormData] = useState({
topic: '',
difficulty: 'beginner',
mode: 'explain',
includeCode: true,
includeQuiz: true,
});
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.topic.trim() || isSubmitting) return;
setIsSubmitting(true);
setError(null);
try {
await createLesson({
topic: formData.topic.trim(),
difficulty: formData.difficulty,
mode: formData.mode,
includeCode: formData.includeCode,
includeQuiz: formData.includeQuiz,
locale: 'ru',
});
router.push('/learning');
} catch (err) {
console.error('Failed to create lesson:', err);
setError('Не удалось создать урок. Попробуйте позже.');
} finally {
setIsSubmitting(false);
}
};
return (
<div className="h-full overflow-y-auto">
<div className="max-w-lg mx-auto px-4 sm:px-6 py-6 sm:py-8">
{/* Header */}
<div className="flex items-center gap-4 mb-6 sm:mb-8">
<Link
href="/learning"
className="w-10 h-10 flex items-center justify-center rounded-xl text-secondary hover:text-primary hover:bg-surface/50 transition-all"
>
<ArrowLeft className="w-5 h-5" />
</Link>
<div>
<h1 className="text-xl sm:text-2xl font-semibold text-primary">Новый урок</h1>
<p className="text-sm text-secondary mt-0.5 hidden sm:block">Создайте интерактивный урок с AI</p>
</div>
</div>
{error && (
<div className="mb-6 p-4 bg-error/10 border border-error/30 rounded-xl text-sm text-error">
{error}
</div>
)}
{/* Form */}
<form onSubmit={handleCreate} className="space-y-5">
<div>
<label className="block text-sm font-medium text-secondary mb-2">
Тема урока <span className="text-error">*</span>
</label>
<input
value={formData.topic}
onChange={(e) => setFormData((f) => ({ ...f, topic: e.target.value }))}
placeholder="Например: Основы Python"
className="w-full px-4 py-3 text-sm bg-elevated/60 border border-border rounded-xl text-primary placeholder:text-muted focus:outline-none focus:border-accent/40 transition-colors"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary mb-3">
Уровень сложности
</label>
<div className="grid grid-cols-2 gap-2">
{difficulties.map((d) => (
<button
key={d.value}
type="button"
onClick={() => setFormData((f) => ({ ...f, difficulty: d.value }))}
className={`flex items-center gap-3 p-3 rounded-xl border transition-all ${
formData.difficulty === d.value
? 'bg-accent/10 border-accent/30 text-primary'
: 'bg-elevated/40 border-border/50 text-muted hover:border-border hover:text-secondary'
}`}
>
<d.icon className={`w-4 h-4 ${formData.difficulty === d.value ? 'text-accent' : ''}`} />
<span className="text-sm">{d.label}</span>
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-secondary mb-2">
Режим обучения
</label>
<select
value={formData.mode}
onChange={(e) => setFormData((f) => ({ ...f, mode: e.target.value }))}
className="w-full px-4 py-3 text-sm bg-elevated/60 border border-border rounded-xl text-primary focus:outline-none focus:border-accent/40 transition-colors"
>
{modes.map((m) => (
<option key={m.value} value={m.value}>
{m.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-secondary mb-3">
Дополнительные материалы
</label>
<div className="flex flex-col sm:flex-row gap-3">
<label
className={`flex-1 flex items-center gap-3 p-4 rounded-xl border cursor-pointer transition-all ${
formData.includeCode
? 'bg-accent/10 border-accent/30 text-primary'
: 'bg-elevated/40 border-border/50 text-muted hover:border-border'
}`}
>
<input
type="checkbox"
checked={formData.includeCode}
onChange={(e) => setFormData((f) => ({ ...f, includeCode: e.target.checked }))}
className="sr-only"
/>
<Code2 className={`w-5 h-5 ${formData.includeCode ? 'text-accent' : ''}`} />
<span className="text-sm">Примеры кода</span>
</label>
<label
className={`flex-1 flex items-center gap-3 p-4 rounded-xl border cursor-pointer transition-all ${
formData.includeQuiz
? 'bg-accent/10 border-accent/30 text-primary'
: 'bg-elevated/40 border-border/50 text-muted hover:border-border'
}`}
>
<input
type="checkbox"
checked={formData.includeQuiz}
onChange={(e) => setFormData((f) => ({ ...f, includeQuiz: e.target.checked }))}
className="sr-only"
/>
<CheckCircle2 className={`w-5 h-5 ${formData.includeQuiz ? 'text-accent' : ''}`} />
<span className="text-sm">Тесты</span>
</label>
</div>
</div>
{/* Actions */}
<div className="flex flex-col-reverse sm:flex-row gap-3 pt-4">
<Link
href="/learning"
className="flex-1 px-4 py-3 text-sm text-center text-secondary hover:text-primary bg-surface/40 border border-border/50 rounded-xl transition-all"
>
Отмена
</Link>
<button
type="submit"
disabled={!formData.topic.trim() || isSubmitting}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 text-sm btn-gradient disabled:opacity-50"
>
{isSubmitting && <Loader2 className="w-4 h-4 animate-spin btn-gradient-text" />}
<span className="btn-gradient-text">Создать урок</span>
</button>
</div>
</form>
</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,522 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap');
:root {
/* Cursor IDE 2026 Dark Theme */
/* Base backgrounds - very dark with slight blue tint */
--bg-base: 240 6% 4%;
--bg-elevated: 240 6% 8%;
--bg-surface: 240 5% 11%;
--bg-overlay: 240 5% 14%;
--bg-muted: 240 4% 18%;
/* Text colors - zinc-based for readability */
--text-primary: 240 5% 92%;
--text-secondary: 240 4% 68%;
--text-muted: 240 4% 48%;
--text-faint: 240 3% 38%;
/* Accent colors - indigo/purple like Cursor */
--accent: 239 84% 67%;
--accent-hover: 239 84% 74%;
--accent-muted: 239 60% 55%;
--accent-subtle: 239 40% 25%;
/* Secondary accent - cyan for variety */
--accent-secondary: 187 85% 55%;
--accent-secondary-muted: 187 60% 40%;
/* Semantic colors */
--success: 142 71% 45%;
--success-muted: 142 50% 35%;
--warning: 38 92% 50%;
--warning-muted: 38 70% 40%;
--error: 0 72% 51%;
--error-muted: 0 60% 40%;
/* Border colors */
--border: 240 4% 16%;
--border-hover: 240 4% 22%;
--border-focus: 239 60% 50%;
/* Legacy mappings for compatibility */
--background: var(--bg-base);
--foreground: var(--text-primary);
--card: var(--bg-elevated);
--card-foreground: var(--text-primary);
--popover: var(--bg-surface);
--popover-foreground: var(--text-primary);
--primary: var(--accent);
--primary-foreground: 0 0% 100%;
--secondary: var(--bg-surface);
--secondary-foreground: var(--text-secondary);
--muted: var(--bg-muted);
--muted-foreground: var(--text-muted);
--accent-color: var(--accent);
--accent-foreground: 0 0% 100%;
--destructive: var(--error);
--destructive-foreground: 0 0% 100%;
--input: var(--bg-surface);
--ring: var(--accent);
--radius: 0.75rem;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
color-scheme: dark;
}
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background: hsl(var(--bg-base));
color: hsl(var(--text-primary));
min-height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
letter-spacing: -0.01em;
}
/* Gradient backgrounds */
.bg-gradient-main {
background: linear-gradient(
180deg,
hsl(240 6% 4%) 0%,
hsl(240 6% 5%) 50%,
hsl(240 5% 6%) 100%
);
}
.bg-gradient-elevated {
background: linear-gradient(
180deg,
hsl(240 6% 9% / 0.9) 0%,
hsl(240 6% 7% / 0.8) 100%
);
}
.bg-gradient-card {
background: linear-gradient(
180deg,
hsl(240 5% 11% / 0.6) 0%,
hsl(240 5% 9% / 0.4) 100%
);
}
/* Accent gradient for special elements */
.bg-gradient-accent {
background: linear-gradient(
135deg,
hsl(239 84% 67%) 0%,
hsl(260 84% 67%) 50%,
hsl(239 84% 67%) 100%
);
}
/* Text gradient */
.text-gradient {
background: linear-gradient(
135deg,
hsl(239 84% 74%) 0%,
hsl(260 90% 75%) 50%,
hsl(187 85% 65%) 100%
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Gradient border button */
.btn-gradient {
position: relative;
background: transparent;
border: none;
border-radius: 0.75rem;
padding: 0.625rem 1rem;
font-size: 0.875rem;
font-weight: 500;
color: hsl(239 84% 74%);
transition: all 0.15s ease;
z-index: 1;
}
.btn-gradient::before {
content: '';
position: absolute;
inset: 0;
border-radius: 0.75rem;
padding: 1.5px;
background: linear-gradient(
135deg,
hsl(239 84% 74%) 0%,
hsl(260 90% 75%) 50%,
hsl(187 85% 65%) 100%
);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
.btn-gradient:hover {
background: linear-gradient(
135deg,
hsl(239 84% 74% / 0.1) 0%,
hsl(260 90% 75% / 0.1) 50%,
hsl(187 85% 65% / 0.1) 100%
);
}
.btn-gradient:active {
transform: scale(0.98);
}
/* Gradient border - larger version */
.btn-gradient-lg {
position: relative;
background: transparent;
border: none;
border-radius: 0.75rem;
padding: 0.75rem 1.25rem;
font-size: 0.875rem;
font-weight: 500;
color: hsl(239 84% 74%);
transition: all 0.15s ease;
z-index: 1;
}
.btn-gradient-lg::before {
content: '';
position: absolute;
inset: 0;
border-radius: 0.75rem;
padding: 2px;
background: linear-gradient(
135deg,
hsl(239 84% 74%) 0%,
hsl(260 90% 75%) 50%,
hsl(187 85% 65%) 100%
);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
.btn-gradient-lg:hover {
background: linear-gradient(
135deg,
hsl(239 84% 74% / 0.1) 0%,
hsl(260 90% 75% / 0.1) 50%,
hsl(187 85% 65% / 0.1) 100%
);
}
/* Gradient text for buttons */
.btn-gradient-text {
background: linear-gradient(
135deg,
hsl(239 84% 74%) 0%,
hsl(260 90% 75%) 50%,
hsl(187 85% 65%) 100%
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Modern thin scrollbars */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: hsl(240 4% 20%);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: hsl(240 4% 28%);
}
::selection {
background: hsl(239 84% 67% / 0.3);
}
/* Code font */
.font-mono {
font-family: 'JetBrains Mono', 'Fira Code', 'SF Mono', Consolas, monospace;
}
@layer base {
* {
@apply border-border;
}
}
@layer components {
/* Glass morphism card */
.glass-card {
@apply bg-surface/60 backdrop-blur-xl border border-border rounded-2xl;
}
/* Surface card with hover */
.surface-card {
@apply bg-elevated/40 backdrop-blur-sm border border-border/50 rounded-xl;
@apply transition-all duration-200;
}
.surface-card:hover {
@apply border-border-hover bg-elevated/60;
}
/* Input styles */
.input-cursor {
@apply bg-surface/50 border border-border rounded-xl px-4 py-3;
@apply text-primary placeholder:text-muted;
@apply focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20;
@apply transition-all duration-200;
}
/* Primary button - gradient accent */
.btn-primary {
@apply bg-accent/10 text-accent-foreground border border-accent/30;
@apply rounded-xl px-4 py-2.5 text-sm font-medium;
@apply hover:bg-accent/20 hover:border-accent/50;
@apply active:bg-accent/25;
@apply transition-all duration-150;
}
.btn-primary-solid {
@apply bg-accent text-white border-none;
@apply rounded-xl px-4 py-2.5 text-sm font-medium;
@apply hover:bg-accent-hover;
@apply active:scale-[0.98];
@apply transition-all duration-150;
}
/* Secondary button */
.btn-secondary {
@apply bg-surface/50 text-secondary border border-border;
@apply rounded-xl px-4 py-2.5 text-sm font-medium;
@apply hover:bg-muted/50 hover:text-primary hover:border-border-hover;
@apply transition-all duration-150;
}
/* Ghost button */
.btn-ghost {
@apply text-secondary hover:text-primary;
@apply hover:bg-surface/50 rounded-xl px-3 py-2;
@apply transition-all duration-150;
}
/* Icon button */
.btn-icon {
@apply w-9 h-9 flex items-center justify-center rounded-xl;
@apply text-muted hover:text-secondary hover:bg-surface/50;
@apply transition-all duration-150;
}
.btn-icon-active {
@apply bg-accent/15 text-accent;
}
/* Navigation item */
.nav-item {
@apply flex items-center gap-3 px-3 py-2.5 rounded-xl;
@apply text-secondary hover:text-primary;
@apply hover:bg-surface/40 transition-all duration-150;
}
.nav-item-active {
@apply bg-accent/10 text-primary;
@apply border-l-2 border-accent;
}
/* Card styles */
.card {
@apply bg-elevated/40 backdrop-blur-sm;
@apply border border-border/50 rounded-2xl;
@apply transition-all duration-200;
}
.card:hover {
@apply border-border bg-elevated/60;
}
.card-interactive {
@apply cursor-pointer;
}
.card-interactive:hover {
@apply border-accent/30 shadow-lg shadow-accent/5;
}
/* Section headers */
.section-header {
@apply text-xs font-semibold uppercase tracking-wider text-muted;
}
/* Badges */
.badge {
@apply inline-flex items-center px-2 py-0.5 rounded-lg text-xs font-medium;
@apply bg-surface text-secondary border border-border/50;
}
.badge-accent {
@apply bg-accent/15 text-accent border-accent/30;
}
.badge-success {
@apply bg-success/15 text-success border-success/30;
}
.badge-warning {
@apply bg-warning/15 text-warning border-warning/30;
}
.badge-error {
@apply bg-error/15 text-error border-error/30;
}
/* Divider */
.divider {
@apply border-t border-border/50;
}
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
/* Glow effects */
.glow-accent {
box-shadow: 0 0 20px hsl(239 84% 67% / 0.15),
0 0 40px hsl(239 84% 67% / 0.1);
}
.glow-accent-strong {
box-shadow: 0 0 30px hsl(239 84% 67% / 0.25),
0 0 60px hsl(239 84% 67% / 0.15);
}
.glow-subtle {
box-shadow: 0 4px 20px hsl(240 6% 4% / 0.5);
}
/* Focus ring */
.focus-ring {
@apply focus:outline-none focus:ring-2 focus:ring-accent/50 focus:ring-offset-2 focus:ring-offset-base;
}
/* Animation delays */
.animate-delay-75 { animation-delay: 75ms; }
.animate-delay-100 { animation-delay: 100ms; }
.animate-delay-150 { animation-delay: 150ms; }
.animate-delay-200 { animation-delay: 200ms; }
.animate-delay-300 { animation-delay: 300ms; }
.animate-delay-400 { animation-delay: 400ms; }
.animate-delay-500 { animation-delay: 500ms; }
}
/* Smooth animations */
@keyframes fade-in {
0% { opacity: 0; transform: translateY(4px); }
100% { opacity: 1; transform: translateY(0); }
}
@keyframes fade-in-up {
0% { opacity: 0; transform: translateY(12px); }
100% { opacity: 1; transform: translateY(0); }
}
@keyframes slide-in-right {
0% { opacity: 0; transform: translateX(-12px); }
100% { opacity: 1; transform: translateX(0); }
}
@keyframes slide-in-left {
0% { opacity: 0; transform: translateX(12px); }
100% { opacity: 1; transform: translateX(0); }
}
@keyframes scale-in {
0% { opacity: 0; transform: scale(0.95); }
100% { opacity: 1; transform: scale(1); }
}
@keyframes pulse-soft {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
@keyframes glow-pulse {
0%, 100% {
box-shadow: 0 0 20px hsl(239 84% 67% / 0.15);
}
50% {
box-shadow: 0 0 30px hsl(239 84% 67% / 0.25);
}
}
.animate-fade-in {
animation: fade-in 0.2s ease-out forwards;
}
.animate-fade-in-up {
animation: fade-in-up 0.3s ease-out forwards;
}
.animate-slide-in-right {
animation: slide-in-right 0.25s ease-out forwards;
}
.animate-slide-in-left {
animation: slide-in-left 0.25s ease-out forwards;
}
.animate-scale-in {
animation: scale-in 0.2s ease-out forwards;
}
.animate-pulse-soft {
animation: pulse-soft 2s ease-in-out infinite;
}
.animate-shimmer {
background: linear-gradient(
90deg,
hsl(240 5% 11%) 0%,
hsl(240 5% 16%) 50%,
hsl(240 5% 11%) 100%
);
background-size: 200% 100%;
animation: shimmer 1.5s linear infinite;
}
.animate-glow-pulse {
animation: glow-pulse 2s ease-in-out infinite;
}

View File

@@ -0,0 +1,19 @@
import type { Metadata } from 'next';
import './globals.css';
export const metadata: Metadata = {
title: 'GooSeek - AI Search',
description: 'AI-поиск нового поколения',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ru" className="dark">
<body className="antialiased">{children}</body>
</html>
);
}

View File

@@ -0,0 +1,208 @@
'use client';
import { useState, useRef, useCallback, KeyboardEvent } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Send,
Paperclip,
Sparkles,
Zap,
Scale,
ChevronDown,
Square,
Globe,
Image as ImageIcon,
ArrowUp,
} from 'lucide-react';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
type Mode = 'speed' | 'balanced' | 'quality';
interface ChatInputProps {
onSend: (message: string, mode: Mode) => void;
onStop?: () => void;
isLoading?: boolean;
placeholder?: string;
autoFocus?: boolean;
}
const modes: { value: Mode; label: string; icon: typeof Zap; desc: string }[] = [
{ value: 'speed', label: 'Быстрый', icon: Zap, desc: '~10 сек' },
{ value: 'balanced', label: 'Баланс', icon: Scale, desc: '~30 сек' },
{ value: 'quality', label: 'Качество', icon: Sparkles, desc: '~60 сек' },
];
export function ChatInput({ onSend, onStop, isLoading, placeholder, autoFocus }: ChatInputProps) {
const [message, setMessage] = useState('');
const [mode, setMode] = useState<Mode>('balanced');
const [isFocused, setIsFocused] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const handleSubmit = useCallback(() => {
if (message.trim() && !isLoading) {
onSend(message.trim(), mode);
setMessage('');
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
}
}, [message, mode, isLoading, onSend]);
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
const handleInput = () => {
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = 'auto';
textarea.style.height = `${Math.min(textarea.scrollHeight, 180)}px`;
}
};
const currentMode = modes.find((m) => m.value === mode)!;
return (
<div className="relative w-full">
<motion.div
animate={{
borderColor: isFocused ? 'hsl(239 84% 67% / 0.4)' : 'hsl(240 4% 16% / 0.8)',
}}
transition={{ duration: 0.15 }}
className={`
relative bg-elevated/60 backdrop-blur-xl border rounded-2xl overflow-hidden
shadow-card transition-shadow duration-200
${isFocused ? 'shadow-glow-sm' : ''}
`}
>
{/* Main Input Area */}
<div className="flex items-end gap-3 p-4 pb-3">
<textarea
ref={textareaRef}
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={handleKeyDown}
onInput={handleInput}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
placeholder={placeholder || 'Задайте любой вопрос...'}
autoFocus={autoFocus}
className="flex-1 bg-transparent text-[15px] text-primary resize-none focus:outline-none min-h-[28px] max-h-[180px] placeholder:text-muted leading-relaxed"
rows={1}
disabled={isLoading}
/>
{/* Submit Button */}
<div className="flex items-center gap-2">
{isLoading ? (
<button
onClick={onStop}
className="w-10 h-10 flex items-center justify-center rounded-xl bg-error/10 text-error border border-error/30 hover:bg-error/20 transition-all"
>
<Square className="w-4 h-4 fill-current" />
</button>
) : (
<button
onClick={handleSubmit}
disabled={!message.trim()}
className={`
w-10 h-10 flex items-center justify-center rounded-xl transition-all duration-200
${message.trim()
? 'btn-gradient'
: 'bg-surface/50 text-muted border border-border/50'
}
`}
>
<ArrowUp className={`w-5 h-5 ${message.trim() ? 'btn-gradient-text' : ''}`} />
</button>
)}
</div>
</div>
{/* Bottom Toolbar */}
<div className="px-4 pb-3 flex items-center justify-between">
<div className="flex items-center gap-1">
{/* Mode Selector */}
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button className="flex items-center gap-2 h-8 px-3 text-xs text-secondary hover:text-primary rounded-lg hover:bg-surface/50 transition-all">
<currentMode.icon className="w-4 h-4 text-accent-muted" />
<span className="font-medium">{currentMode.label}</span>
<ChevronDown className="w-3.5 h-3.5 opacity-50" />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="min-w-[180px] bg-surface/95 backdrop-blur-xl border border-border rounded-xl p-1.5 shadow-dropdown z-50"
sideOffset={8}
>
{modes.map((m) => (
<DropdownMenu.Item
key={m.value}
onClick={() => setMode(m.value)}
className={`
flex items-center gap-3 px-3 py-2.5 text-sm rounded-lg cursor-pointer outline-none transition-colors
${mode === m.value
? 'bg-accent/10 text-primary'
: 'text-secondary hover:bg-elevated/80 hover:text-primary'
}
`}
>
<m.icon className={`w-4 h-4 ${mode === m.value ? 'text-accent' : 'text-muted'}`} />
<span className="flex-1 font-medium">{m.label}</span>
<span className="text-xs text-muted">{m.desc}</span>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
{/* Separator */}
<div className="w-px h-5 bg-border/50 mx-1" />
{/* Action Buttons */}
<button
className="btn-icon w-8 h-8"
title="Веб-поиск"
>
<Globe className="w-4 h-4" />
</button>
<button
className="btn-icon w-8 h-8"
title="Прикрепить файл"
>
<Paperclip className="w-4 h-4" />
</button>
<button
className="btn-icon w-8 h-8"
title="Изображение"
>
<ImageIcon className="w-4 h-4" />
</button>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-faint">
Enter для отправки · Shift+Enter для новой строки
</span>
</div>
</div>
</motion.div>
{/* Focus Glow Effect */}
<AnimatePresence>
{isFocused && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute -inset-0.5 bg-gradient-to-r from-accent/10 via-accent/20 to-accent/10 rounded-2xl -z-10 blur-xl"
/>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,275 @@
'use client';
import { useMemo, useState } from 'react';
import { motion } from 'framer-motion';
import ReactMarkdown from 'react-markdown';
import { User, Bot, Copy, Check, ExternalLink, ThumbsUp, ThumbsDown, Share2, Sparkles } from 'lucide-react';
import * as Tooltip from '@radix-ui/react-tooltip';
import type { Message, Citation as CitationType } from '@/lib/types';
interface ChatMessageProps {
message: Message;
}
export function ChatMessage({ message }: ChatMessageProps) {
const [copied, setCopied] = useState(false);
const [feedback, setFeedback] = useState<'up' | 'down' | null>(null);
const isUser = message.role === 'user';
const handleCopy = async () => {
await navigator.clipboard.writeText(message.content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: 'easeOut' }}
className={`group ${isUser ? 'flex justify-end' : ''}`}
>
{isUser ? (
<div className="max-w-[85%] flex items-start gap-3 flex-row-reverse">
<div className="w-9 h-9 rounded-xl bg-accent/10 border border-accent/20 flex items-center justify-center flex-shrink-0">
<User className="w-4 h-4 text-accent" />
</div>
<div className="bg-surface/60 border border-border/50 rounded-2xl rounded-tr-sm px-4 py-3">
<p className="text-[15px] text-primary leading-relaxed">{message.content}</p>
</div>
</div>
) : (
<div className="max-w-full">
{/* Assistant Header */}
<div className="flex items-center gap-2.5 mb-4">
<div className="w-9 h-9 rounded-xl bg-accent/10 border border-accent/20 flex items-center justify-center">
<Sparkles className="w-4 h-4 text-accent" />
</div>
<span className="text-sm font-medium text-secondary">GooSeek</span>
</div>
{/* Citations - Show at top like Perplexity */}
{message.citations && message.citations.length > 0 && (
<div className="mb-5">
<div className="flex flex-wrap gap-2">
{message.citations.slice(0, 6).map((citation) => (
<CitationBadge key={citation.index} citation={citation} />
))}
{message.citations.length > 6 && (
<button className="text-xs text-muted hover:text-secondary px-2.5 py-1.5 rounded-lg hover:bg-surface/40 transition-colors">
+{message.citations.length - 6} ещё
</button>
)}
</div>
</div>
)}
{/* Message Content */}
<div className="prose prose-invert max-w-none">
<ReactMarkdown
components={{
p: ({ children }) => (
<p className="mb-4 last:mb-0 text-[15px] text-primary/90 leading-[1.7]">
{children}
</p>
),
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:text-accent-hover underline underline-offset-2 decoration-accent/30 hover:decoration-accent/50 transition-colors"
>
{children}
</a>
),
code: ({ className, children }) => {
const isInline = !className;
if (isInline) {
return (
<code className="px-1.5 py-0.5 bg-surface/80 border border-border/50 rounded-md text-[13px] text-accent-secondary font-mono">
{children}
</code>
);
}
return <code className={className}>{children}</code>;
},
pre: ({ children }) => (
<pre className="bg-elevated/80 border border-border/50 p-4 rounded-xl overflow-x-auto text-[13px] my-5 font-mono">
{children}
</pre>
),
ul: ({ children }) => (
<ul className="list-none pl-0 my-4 space-y-2.5">{children}</ul>
),
ol: ({ children }) => (
<ol className="list-none pl-0 my-4 space-y-2.5 counter-reset-item">
{children}
</ol>
),
li: ({ children }) => (
<li className="flex gap-3 text-[15px] text-primary/85">
<span className="text-accent/60 select-none mt-0.5"></span>
<span className="leading-[1.6]">{children}</span>
</li>
),
h1: ({ children }) => (
<h1 className="text-xl font-semibold text-primary mt-8 mb-4">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-lg font-semibold text-primary mt-6 mb-3">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-base font-medium text-primary mt-5 mb-2">
{children}
</h3>
),
blockquote: ({ children }) => (
<blockquote className="border-l-2 border-accent/40 pl-4 my-5 text-secondary italic">
{children}
</blockquote>
),
strong: ({ children }) => (
<strong className="font-semibold text-primary">{children}</strong>
),
}}
>
{message.content}
</ReactMarkdown>
{/* Streaming Cursor */}
{message.isStreaming && (
<span className="inline-block w-2 h-5 bg-accent/60 rounded-sm animate-pulse-soft ml-1" />
)}
</div>
{/* Actions */}
{!message.isStreaming && (
<div className="flex items-center gap-1 mt-5 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<ActionButton
icon={copied ? Check : Copy}
label={copied ? 'Скопировано' : 'Копировать'}
onClick={handleCopy}
active={copied}
/>
<ActionButton
icon={Share2}
label="Поделиться"
onClick={() => {}}
/>
<div className="w-px h-4 bg-border/50 mx-1.5" />
<ActionButton
icon={ThumbsUp}
label="Полезно"
onClick={() => setFeedback('up')}
active={feedback === 'up'}
/>
<ActionButton
icon={ThumbsDown}
label="Не полезно"
onClick={() => setFeedback('down')}
active={feedback === 'down'}
/>
</div>
)}
</div>
)}
</motion.div>
);
}
interface ActionButtonProps {
icon: React.ElementType;
label: string;
onClick: () => void;
active?: boolean;
}
function ActionButton({ icon: Icon, label, onClick, active }: ActionButtonProps) {
return (
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
onClick={onClick}
className={`
p-2 rounded-lg transition-all
${active
? 'text-accent bg-accent/10'
: 'text-muted hover:text-secondary hover:bg-surface/50'
}
`}
>
<Icon className="w-4 h-4" />
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
side="top"
className="px-2.5 py-1.5 text-xs text-primary bg-surface border border-border rounded-lg shadow-dropdown z-50"
sideOffset={4}
>
{label}
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}
function CitationBadge({ citation }: { citation: CitationType }) {
return (
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<a
href={citation.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-3 py-2 bg-surface/40 hover:bg-surface/60 border border-border/50 hover:border-accent/30 rounded-xl transition-all group"
>
<span className="w-5 h-5 rounded-md bg-accent/15 text-accent flex items-center justify-center text-xs font-semibold">
{citation.index}
</span>
{citation.favicon && (
<img
src={citation.favicon}
alt=""
className="w-4 h-4 rounded"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
)}
<span className="text-sm text-secondary group-hover:text-primary max-w-[140px] truncate transition-colors">
{citation.domain}
</span>
<ExternalLink className="w-3 h-3 text-muted group-hover:text-secondary transition-colors" />
</a>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
side="top"
className="max-w-[300px] p-4 bg-surface/95 backdrop-blur-xl border border-border rounded-xl shadow-dropdown z-50"
sideOffset={8}
>
<p className="font-medium text-sm text-primary line-clamp-2 mb-2">
{citation.title}
</p>
{citation.snippet && (
<p className="text-xs text-secondary line-clamp-2 mb-2">
{citation.snippet}
</p>
)}
<p className="text-xs text-muted truncate">{citation.url}</p>
<Tooltip.Arrow className="fill-surface" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}

View File

@@ -0,0 +1,146 @@
'use client';
import { ExternalLink } from 'lucide-react';
import * as Tooltip from '@radix-ui/react-tooltip';
import type { Citation as CitationType } from '@/lib/types';
interface CitationProps {
citation: CitationType;
compact?: boolean;
}
export function Citation({ citation, compact }: CitationProps) {
if (compact) {
return (
<a
href={citation.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center w-5 h-5 text-2xs font-medium bg-cream-300/10 hover:bg-cream-300/20 text-cream-300 border border-cream-400/20 rounded transition-colors"
>
{citation.index}
</a>
);
}
return (
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<a
href={citation.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-2.5 py-1.5 bg-navy-800/40 hover:bg-navy-800/60 border border-navy-700/30 hover:border-cream-400/20 rounded-lg transition-all group"
>
<span className="w-4 h-4 rounded bg-cream-300/10 text-cream-300 flex items-center justify-center text-2xs font-medium">
{citation.index}
</span>
{citation.favicon && (
<img
src={citation.favicon}
alt=""
className="w-3.5 h-3.5 rounded"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
)}
<span className="text-xs text-cream-400/80 group-hover:text-cream-200 max-w-[120px] truncate transition-colors">
{citation.domain}
</span>
<ExternalLink className="w-2.5 h-2.5 text-cream-500/50 group-hover:text-cream-400/70 transition-colors" />
</a>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
side="top"
className="max-w-[300px] p-4 bg-navy-800/95 backdrop-blur-xl border border-navy-700/50 rounded-xl shadow-xl z-50"
sideOffset={8}
>
<p className="font-medium text-sm text-cream-100 line-clamp-2 mb-2">
{citation.title}
</p>
{citation.snippet && (
<p className="text-xs text-cream-400/70 line-clamp-3 mb-3">
{citation.snippet}
</p>
)}
<div className="flex items-center gap-2 text-2xs text-cream-500/60">
{citation.favicon && (
<img
src={citation.favicon}
alt=""
className="w-3 h-3 rounded"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
)}
<span className="truncate">{citation.domain}</span>
</div>
<Tooltip.Arrow className="fill-navy-800" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}
interface CitationListProps {
citations: CitationType[];
maxVisible?: number;
}
export function CitationList({ citations, maxVisible = 6 }: CitationListProps) {
if (!citations || citations.length === 0) return null;
const visible = citations.slice(0, maxVisible);
const remaining = citations.length - maxVisible;
return (
<div className="flex flex-wrap gap-2">
{visible.map((citation) => (
<Citation key={citation.index} citation={citation} />
))}
{remaining > 0 && (
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button className="text-xs text-cream-500/70 hover:text-cream-300 px-2.5 py-1.5 rounded-lg hover:bg-navy-800/30 transition-colors">
+{remaining} ещё
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
side="top"
className="max-w-[320px] p-3 bg-navy-800/95 backdrop-blur-xl border border-navy-700/50 rounded-xl shadow-xl z-50"
sideOffset={8}
>
<div className="space-y-2">
{citations.slice(maxVisible).map((citation) => (
<a
key={citation.index}
href={citation.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 p-2 rounded-lg hover:bg-navy-700/50 transition-colors"
>
<span className="w-4 h-4 rounded bg-cream-300/10 text-cream-300 flex items-center justify-center text-2xs font-medium flex-shrink-0">
{citation.index}
</span>
<span className="text-xs text-cream-200 truncate">
{citation.title}
</span>
</a>
))}
</div>
<Tooltip.Arrow className="fill-navy-800" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
)}
</div>
);
}

View File

@@ -0,0 +1,152 @@
'use client';
import { ExternalLink, FileText, Sparkles, Clock } from 'lucide-react';
import type { DiscoverItem } from '@/lib/types';
interface DiscoverCardProps {
item: DiscoverItem;
variant?: 'large' | 'medium' | 'small';
onSummarize?: (url: string) => void;
}
export function DiscoverCard({ item, variant = 'medium', onSummarize }: DiscoverCardProps) {
const domain = item.url ? new URL(item.url).hostname.replace('www.', '') : '';
if (variant === 'large') {
return (
<article className="group relative overflow-hidden rounded-2xl bg-navy-900/40 border border-navy-700/20 hover:border-cream-400/15 transition-all duration-300">
{item.thumbnail && (
<div className="aspect-video overflow-hidden">
<img
src={item.thumbnail}
alt={item.title}
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>
)}
<div className="p-6">
<div className="flex items-center gap-2 mb-3">
<img
src={`https://www.google.com/s2/favicons?domain=${domain}&sz=16`}
alt=""
className="w-4 h-4 rounded"
/>
<span className="text-xs text-cream-500/60">{domain}</span>
{item.sourcesCount && item.sourcesCount > 1 && (
<span className="text-xs text-cream-600/40">
{item.sourcesCount} источников
</span>
)}
</div>
<h2 className="text-xl font-semibold text-cream-100 mb-3 line-clamp-2 group-hover:text-cream-50 transition-colors">
{item.title}
</h2>
<p className="text-cream-400/70 text-sm line-clamp-3 mb-5">{item.content}</p>
<div className="flex items-center gap-4">
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-cream-400/80 hover:text-cream-200 transition-colors"
>
<ExternalLink className="w-4 h-4" />
Читать
</a>
{onSummarize && (
<button
onClick={() => onSummarize(item.url)}
className="flex items-center gap-2 text-sm text-cream-300 hover:text-cream-100 transition-colors"
>
<Sparkles className="w-4 h-4" />
AI Саммари
</button>
)}
</div>
</div>
</article>
);
}
if (variant === 'small') {
return (
<article className="group flex items-start gap-3 p-3 rounded-xl hover:bg-navy-800/30 transition-colors">
{item.thumbnail && (
<img
src={item.thumbnail}
alt=""
className="w-16 h-16 rounded-lg object-cover flex-shrink-0"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-1">
<img
src={`https://www.google.com/s2/favicons?domain=${domain}&sz=16`}
alt=""
className="w-3 h-3 rounded"
/>
<span className="text-xs text-cream-600/50 truncate">{domain}</span>
</div>
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-cream-200 group-hover:text-cream-100 line-clamp-2 transition-colors"
>
{item.title}
</a>
</div>
</article>
);
}
return (
<article className="group p-4 bg-navy-900/30 border border-navy-700/20 rounded-xl hover:border-cream-400/15 hover:bg-navy-900/50 transition-all duration-200">
{item.thumbnail && (
<div className="aspect-video rounded-lg overflow-hidden mb-4 -mx-1 -mt-1">
<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>
)}
<div className="flex items-center gap-2 mb-2">
<img
src={`https://www.google.com/s2/favicons?domain=${domain}&sz=16`}
alt=""
className="w-4 h-4 rounded"
/>
<span className="text-xs text-cream-600/50">{domain}</span>
</div>
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="block"
>
<h3 className="font-medium text-cream-100 mb-2 line-clamp-2 group-hover:text-cream-50 transition-colors">
{item.title}
</h3>
</a>
<p className="text-sm text-cream-500/60 line-clamp-2">{item.content}</p>
{onSummarize && (
<button
onClick={() => onSummarize(item.url)}
className="flex items-center gap-2 mt-3 text-xs text-cream-400 hover:text-cream-200 transition-colors"
>
<Sparkles className="w-3.5 h-3.5" />
Саммари
</button>
)}
</article>
);
}

View File

@@ -0,0 +1,198 @@
'use client';
import { useState, useCallback } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { motion, AnimatePresence } from 'framer-motion';
import {
Search,
Compass,
FolderOpen,
Clock,
Settings,
Plus,
ChevronLeft,
ChevronRight,
TrendingUp,
BookOpen,
Cpu,
X,
} from 'lucide-react';
interface SidebarProps {
onClose?: () => void;
}
const navItems = [
{ href: '/', icon: Search, label: 'Поиск' },
{ href: '/discover', icon: Compass, label: 'Discover' },
{ href: '/spaces', icon: FolderOpen, label: 'Пространства' },
{ href: '/history', icon: Clock, label: 'История' },
];
const toolItems = [
{ href: '/computer', icon: Cpu, label: 'Computer' },
{ href: '/finance', icon: TrendingUp, label: 'Финансы' },
{ href: '/learning', icon: BookOpen, label: 'Обучение' },
];
export function Sidebar({ onClose }: SidebarProps) {
const pathname = usePathname();
const [collapsed, setCollapsed] = useState(false);
const isMobile = !!onClose;
const toggleCollapse = useCallback(() => {
setCollapsed((prev) => !prev);
}, []);
const handleNavClick = () => {
if (onClose) {
onClose();
}
};
return (
<motion.aside
initial={false}
animate={{ width: isMobile ? 280 : collapsed ? 68 : 260 }}
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
className="h-full flex flex-col bg-base border-r border-border/50"
>
{/* Header */}
<div className="h-16 px-4 flex items-center justify-between">
<AnimatePresence mode="wait">
{(isMobile || !collapsed) && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<span className="font-bold italic text-primary tracking-tight text-lg">GooSeek</span>
</motion.div>
)}
</AnimatePresence>
{isMobile ? (
<button
onClick={onClose}
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>
) : (
<button
onClick={toggleCollapse}
className="w-8 h-8 flex items-center justify-center rounded-lg text-muted hover:text-secondary hover:bg-surface/50 transition-all"
>
{collapsed ? (
<ChevronRight className="w-4 h-4" />
) : (
<ChevronLeft className="w-4 h-4" />
)}
</button>
)}
</div>
{/* New Chat Button */}
<div className="px-3 mb-4">
<Link
href="/"
onClick={handleNavClick}
className={`
w-full flex items-center gap-3 h-11 btn-gradient
${isMobile || !collapsed ? 'px-3' : 'justify-center px-0'}
`}
>
<Plus className="w-[18px] h-[18px] flex-shrink-0 btn-gradient-text" />
{(isMobile || !collapsed) && (
<span className="text-sm font-medium truncate btn-gradient-text">Новый чат</span>
)}
</Link>
</div>
{/* Separator */}
<div className="mx-4 border-t border-border/50" />
{/* Navigation */}
<nav className="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
{navItems.map((item) => (
<NavLink
key={item.href}
href={item.href}
icon={item.icon}
label={item.label}
collapsed={!isMobile && collapsed}
active={pathname === item.href}
onClick={handleNavClick}
/>
))}
{/* Tools Section */}
<div className="pt-6 pb-2">
{(isMobile || !collapsed) && (
<span className="px-3 text-[11px] font-semibold text-muted uppercase tracking-wider">
Инструменты
</span>
)}
{!isMobile && collapsed && <div className="h-px bg-border/30 mx-2" />}
</div>
{toolItems.map((item) => (
<NavLink
key={item.href}
href={item.href}
icon={item.icon}
label={item.label}
collapsed={!isMobile && collapsed}
active={pathname === item.href}
onClick={handleNavClick}
/>
))}
</nav>
{/* Footer */}
<div className="p-3 border-t border-border/30">
<NavLink
href="/settings"
icon={Settings}
label="Настройки"
collapsed={!isMobile && collapsed}
active={pathname === '/settings'}
onClick={handleNavClick}
/>
</div>
</motion.aside>
);
}
interface NavLinkProps {
href: string;
icon: React.ElementType;
label: string;
collapsed: boolean;
active: boolean;
onClick?: () => void;
}
function NavLink({ href, icon: Icon, label, collapsed, active, onClick }: NavLinkProps) {
return (
<Link
href={href}
onClick={onClick}
className={`
flex items-center gap-3 h-11 rounded-xl transition-all duration-150
${collapsed ? 'justify-center px-0' : 'px-3'}
${active
? 'bg-accent/10 text-primary border-l-2 border-accent ml-0'
: 'text-secondary hover:text-primary hover:bg-surface/50'
}
`}
>
<Icon className={`w-[18px] h-[18px] flex-shrink-0 ${active ? 'text-accent' : ''}`} />
{!collapsed && (
<span className="text-sm font-medium truncate">{label}</span>
)}
</Link>
);
}

View File

@@ -0,0 +1,5 @@
export { Sidebar } from './Sidebar';
export { ChatInput } from './ChatInput';
export { ChatMessage } from './ChatMessage';
export { Citation, CitationList } from './Citation';
export { DiscoverCard } from './DiscoverCard';

View File

@@ -0,0 +1,579 @@
import type {
ChatRequest,
DiscoverItem,
StreamEvent,
Thread,
Space,
FinanceMarket,
HeatmapData,
TopMovers,
Lesson,
LessonStep,
ComputerTask,
ComputerExecuteRequest,
ComputerTaskEvent,
ComputerModel,
ComputerConnector,
Artifact,
} from './types';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
function getAuthHeaders(): HeadersInit {
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
if (typeof window !== 'undefined') {
const token = localStorage.getItem('token');
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
}
return headers;
}
export async function* streamChat(request: ChatRequest): AsyncGenerator<StreamEvent> {
const response = await fetch(`${API_BASE}/api/chat`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(request),
});
if (!response.ok) {
throw new Error(`Chat request failed: ${response.status}`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('No response body');
}
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.trim()) {
try {
const event = JSON.parse(line) as StreamEvent;
yield event;
} catch {
console.warn('Failed to parse stream event:', line);
}
}
}
}
if (buffer.trim()) {
try {
const event = JSON.parse(buffer) as StreamEvent;
yield event;
} catch {
console.warn('Failed to parse final buffer:', buffer);
}
}
}
export async function fetchDiscover(topic = 'tech', region = 'russia'): Promise<DiscoverItem[]> {
const response = await fetch(
`${API_BASE}/api/v1/discover?topic=${encodeURIComponent(topic)}&region=${encodeURIComponent(region)}`,
{ headers: getAuthHeaders() }
);
if (!response.ok) {
throw new Error(`Discover fetch failed: ${response.status}`);
}
const data = await response.json();
return data.blogs || [];
}
export async function fetchConfig(): Promise<{ values: Record<string, unknown> }> {
const response = await fetch(`${API_BASE}/api/v1/config`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
throw new Error(`Config fetch failed: ${response.status}`);
}
return response.json();
}
export async function healthCheck(): Promise<boolean> {
try {
const response = await fetch(`${API_BASE}/health`);
return response.ok;
} catch {
return false;
}
}
export function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
}
export async function fetchThreads(limit = 50, offset = 0): Promise<Thread[]> {
const response = await fetch(
`${API_BASE}/api/v1/threads?limit=${limit}&offset=${offset}`,
{ headers: getAuthHeaders() }
);
if (!response.ok) {
if (response.status === 401) return [];
throw new Error(`Threads fetch failed: ${response.status}`);
}
const data = await response.json();
return data.threads || [];
}
export async function fetchThread(id: string): Promise<Thread | null> {
const response = await fetch(`${API_BASE}/api/v1/threads/${id}`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
if (response.status === 404) return null;
throw new Error(`Thread fetch failed: ${response.status}`);
}
return response.json();
}
export async function createThread(data: { title?: string; focusMode?: string; spaceId?: string }): Promise<Thread> {
const response = await fetch(`${API_BASE}/api/v1/threads`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Thread create failed: ${response.status}`);
}
return response.json();
}
export async function deleteThread(id: string): Promise<void> {
const response = await fetch(`${API_BASE}/api/v1/threads/${id}`, {
method: 'DELETE',
headers: getAuthHeaders(),
});
if (!response.ok) {
throw new Error(`Thread delete failed: ${response.status}`);
}
}
export async function shareThread(id: string): Promise<{ shareId: string; shareUrl: string }> {
const response = await fetch(`${API_BASE}/api/v1/threads/${id}/share`, {
method: 'POST',
headers: getAuthHeaders(),
});
if (!response.ok) {
throw new Error(`Thread share failed: ${response.status}`);
}
return response.json();
}
export async function fetchSpaces(): Promise<Space[]> {
const response = await fetch(`${API_BASE}/api/v1/spaces`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
if (response.status === 401) return [];
throw new Error(`Spaces fetch failed: ${response.status}`);
}
const data = await response.json();
return data.spaces || [];
}
export async function fetchSpace(id: string): Promise<Space | null> {
const response = await fetch(`${API_BASE}/api/v1/spaces/${id}`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
if (response.status === 404) return null;
throw new Error(`Space fetch failed: ${response.status}`);
}
return response.json();
}
export async function createSpace(data: { name: string; description?: string; instructions?: string; focusMode?: string }): Promise<Space> {
const response = await fetch(`${API_BASE}/api/v1/spaces`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Space create failed: ${response.status}`);
}
return response.json();
}
export async function updateSpace(id: string, data: Partial<Space>): Promise<Space> {
const response = await fetch(`${API_BASE}/api/v1/spaces/${id}`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Space update failed: ${response.status}`);
}
return response.json();
}
export async function deleteSpace(id: string): Promise<void> {
const response = await fetch(`${API_BASE}/api/v1/spaces/${id}`, {
method: 'DELETE',
headers: getAuthHeaders(),
});
if (!response.ok) {
throw new Error(`Space delete failed: ${response.status}`);
}
}
export async function fetchMarkets(): Promise<FinanceMarket[]> {
const response = await fetch(`${API_BASE}/api/v1/markets`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
throw new Error(`Markets fetch failed: ${response.status}`);
}
const data = await response.json();
return data.markets || [];
}
export async function fetchHeatmap(market: string, timeRange = '1d'): Promise<HeatmapData> {
const response = await fetch(
`${API_BASE}/api/v1/heatmap/${market}?range=${timeRange}`,
{ headers: getAuthHeaders() }
);
if (!response.ok) {
throw new Error(`Heatmap fetch failed: ${response.status}`);
}
return response.json();
}
export async function fetchTopMovers(market: string, count = 10): Promise<TopMovers> {
const response = await fetch(
`${API_BASE}/api/v1/movers/${market}?count=${count}`,
{ headers: getAuthHeaders() }
);
if (!response.ok) {
throw new Error(`Top movers fetch failed: ${response.status}`);
}
return response.json();
}
export async function fetchLessons(limit = 20, offset = 0): Promise<{ lessons: Lesson[]; count: number }> {
const response = await fetch(
`${API_BASE}/api/v1/learning/lessons?limit=${limit}&offset=${offset}`,
{ headers: getAuthHeaders() }
);
if (!response.ok) {
throw new Error(`Lessons fetch failed: ${response.status}`);
}
return response.json();
}
export async function fetchLesson(id: string): Promise<{ lesson: Lesson; steps: LessonStep[] } | null> {
const response = await fetch(`${API_BASE}/api/v1/learning/lessons/${id}`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
if (response.status === 404) return null;
throw new Error(`Lesson fetch failed: ${response.status}`);
}
return response.json();
}
export async function createLesson(data: {
topic: string;
query?: string;
difficulty?: string;
mode?: string;
maxSteps?: number;
locale?: string;
includeCode?: boolean;
includeQuiz?: boolean;
}): Promise<Lesson> {
const response = await fetch(`${API_BASE}/api/v1/learning/lesson`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
topic: data.topic,
query: data.query || data.topic,
difficulty: data.difficulty || 'beginner',
mode: data.mode || 'explain',
maxSteps: data.maxSteps || 5,
locale: data.locale || 'ru',
includeCode: data.includeCode ?? true,
includeQuiz: data.includeQuiz ?? true,
}),
});
if (!response.ok) {
throw new Error(`Lesson create failed: ${response.status}`);
}
return response.json();
}
export async function completeStep(lessonId: string, stepIndex: number): Promise<{ success: boolean; progress: unknown }> {
const response = await fetch(`${API_BASE}/api/v1/learning/lessons/${lessonId}/complete-step`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ stepIndex }),
});
if (!response.ok) {
throw new Error(`Step complete failed: ${response.status}`);
}
return response.json();
}
export async function submitQuizAnswer(
lessonId: string,
stepIndex: number,
selectedOptions: string[]
): Promise<{ correct: boolean; explanation?: string; progress: unknown }> {
const response = await fetch(`${API_BASE}/api/v1/learning/lessons/${lessonId}/submit-answer`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ stepIndex, selectedOptions }),
});
if (!response.ok) {
throw new Error(`Quiz submit failed: ${response.status}`);
}
return response.json();
}
export async function deleteLesson(id: string): Promise<void> {
const response = await fetch(`${API_BASE}/api/v1/learning/lessons/${id}`, {
method: 'DELETE',
headers: getAuthHeaders(),
});
if (!response.ok) {
throw new Error(`Lesson delete failed: ${response.status}`);
}
}
// =====================
// Computer API (Perplexity Computer style)
// =====================
export async function executeComputerTask(request: ComputerExecuteRequest): Promise<ComputerTask> {
const response = await fetch(`${API_BASE}/api/v1/computer/execute`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(request),
});
if (!response.ok) {
throw new Error(`Computer execute failed: ${response.status}`);
}
return response.json();
}
export async function fetchComputerTasks(
userId?: string,
limit = 20,
offset = 0
): Promise<{ tasks: ComputerTask[]; count: number }> {
const params = new URLSearchParams({
limit: limit.toString(),
offset: offset.toString(),
});
if (userId) {
params.set('userId', userId);
}
const response = await fetch(`${API_BASE}/api/v1/computer/tasks?${params}`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
throw new Error(`Computer tasks fetch failed: ${response.status}`);
}
return response.json();
}
export async function fetchComputerTask(id: string): Promise<ComputerTask | null> {
const response = await fetch(`${API_BASE}/api/v1/computer/tasks/${id}`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
if (response.status === 404) return null;
throw new Error(`Computer task fetch failed: ${response.status}`);
}
return response.json();
}
export async function* streamComputerTask(id: string): AsyncGenerator<ComputerTaskEvent> {
const response = await fetch(`${API_BASE}/api/v1/computer/tasks/${id}/stream`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
throw new Error(`Computer stream failed: ${response.status}`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('No response body');
}
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const event = JSON.parse(line.slice(6)) as ComputerTaskEvent;
yield event;
} catch {
console.warn('Failed to parse computer event:', line);
}
}
}
}
}
export async function resumeComputerTask(id: string, userInput: string): Promise<{ status: string }> {
const response = await fetch(`${API_BASE}/api/v1/computer/tasks/${id}/resume`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ userInput }),
});
if (!response.ok) {
throw new Error(`Computer resume failed: ${response.status}`);
}
return response.json();
}
export async function cancelComputerTask(id: string): Promise<{ status: string }> {
const response = await fetch(`${API_BASE}/api/v1/computer/tasks/${id}`, {
method: 'DELETE',
headers: getAuthHeaders(),
});
if (!response.ok) {
throw new Error(`Computer cancel failed: ${response.status}`);
}
return response.json();
}
export async function fetchComputerArtifacts(taskId: string): Promise<{ artifacts: Artifact[]; count: number }> {
const response = await fetch(`${API_BASE}/api/v1/computer/tasks/${taskId}/artifacts`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
throw new Error(`Computer artifacts fetch failed: ${response.status}`);
}
return response.json();
}
export async function downloadArtifact(id: string): Promise<Blob> {
const response = await fetch(`${API_BASE}/api/v1/computer/artifacts/${id}/download`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
throw new Error(`Artifact download failed: ${response.status}`);
}
return response.blob();
}
export async function fetchComputerModels(): Promise<{ models: ComputerModel[]; count: number }> {
const response = await fetch(`${API_BASE}/api/v1/computer/models`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
throw new Error(`Computer models fetch failed: ${response.status}`);
}
return response.json();
}
export async function fetchComputerConnectors(): Promise<{ connectors: ComputerConnector[]; count: number }> {
const response = await fetch(`${API_BASE}/api/v1/computer/connectors`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
throw new Error(`Computer connectors fetch failed: ${response.status}`);
}
return response.json();
}
export async function executeConnectorAction(
connectorId: string,
action: string,
params: Record<string, unknown>
): Promise<unknown> {
const response = await fetch(`${API_BASE}/api/v1/computer/connectors/${connectorId}/execute`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ action, params }),
});
if (!response.ok) {
throw new Error(`Connector execute failed: ${response.status}`);
}
return response.json();
}

View File

@@ -0,0 +1,177 @@
'use client';
import { useState, useCallback, useRef } from 'react';
import type { Message, Citation, Widget, StreamEvent } from '../types';
import { streamChat, generateId } from '../api';
interface UseChatOptions {
onError?: (error: Error) => void;
}
export function useChat(options: UseChatOptions = {}) {
const [messages, setMessages] = useState<Message[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [citations, setCitations] = useState<Citation[]>([]);
const [widgets, setWidgets] = useState<Widget[]>([]);
const abortControllerRef = useRef<AbortController | null>(null);
const sendMessage = useCallback(async (content: string, mode: 'speed' | 'balanced' | 'quality' = 'balanced') => {
if (!content.trim() || isLoading) return;
const userMessage: Message = {
id: generateId(),
role: 'user',
content: content.trim(),
createdAt: new Date(),
};
const assistantMessage: Message = {
id: generateId(),
role: 'assistant',
content: '',
citations: [],
widgets: [],
isStreaming: true,
createdAt: new Date(),
};
setMessages((prev) => [...prev, userMessage, assistantMessage]);
setIsLoading(true);
setCitations([]);
setWidgets([]);
const history: [string, string][] = messages
.filter((m) => !m.isStreaming)
.reduce((acc, m, i, arr) => {
if (m.role === 'user' && arr[i + 1]?.role === 'assistant') {
acc.push([m.content, arr[i + 1].content]);
}
return acc;
}, [] as [string, string][]);
try {
const stream = streamChat({
message: {
messageId: assistantMessage.id,
chatId: userMessage.id,
content: content.trim(),
},
optimizationMode: mode,
history,
locale: 'ru',
});
let fullContent = '';
const collectedCitations: Citation[] = [];
const collectedWidgets: Widget[] = [];
for await (const event of stream) {
switch (event.type) {
case 'messageStart':
break;
case 'message':
if (event.data && typeof event.data === 'object' && 'content' in event.data) {
const chunk = (event.data as { content: string }).content;
fullContent += chunk;
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMessage.id
? { ...m, content: fullContent }
: m
)
);
}
break;
case 'sources':
if (event.data && Array.isArray(event.data)) {
const sources = event.data as Array<{
url: string;
title?: string;
metadata?: { title?: string };
}>;
sources.forEach((source, idx) => {
const citation: Citation = {
index: idx + 1,
url: source.url,
title: source.title || source.metadata?.title || new URL(source.url).hostname,
domain: new URL(source.url).hostname,
};
collectedCitations.push(citation);
});
setCitations([...collectedCitations]);
}
break;
case 'widget':
if (event.data && typeof event.data === 'object') {
const widget = event.data as Widget;
collectedWidgets.push(widget);
setWidgets([...collectedWidgets]);
}
break;
case 'messageEnd':
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMessage.id
? {
...m,
content: fullContent,
citations: collectedCitations,
widgets: collectedWidgets,
isStreaming: false,
}
: m
)
);
break;
case 'error':
throw new Error(
event.data && typeof event.data === 'object' && 'message' in event.data
? String((event.data as { message: string }).message)
: 'Unknown error'
);
}
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Ошибка при отправке сообщения';
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMessage.id
? { ...m, content: `Ошибка: ${errorMessage}`, isStreaming: false }
: m
)
);
options.onError?.(error instanceof Error ? error : new Error(errorMessage));
} finally {
setIsLoading(false);
}
}, [isLoading, messages, options]);
const stopGeneration = useCallback(() => {
abortControllerRef.current?.abort();
setIsLoading(false);
setMessages((prev) =>
prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m))
);
}, []);
const clearMessages = useCallback(() => {
setMessages([]);
setCitations([]);
setWidgets([]);
}, []);
return {
messages,
isLoading,
citations,
widgets,
sendMessage,
stopGeneration,
clearMessages,
};
}

View File

@@ -0,0 +1,441 @@
export interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
citations?: Citation[];
widgets?: Widget[];
isStreaming?: boolean;
createdAt: Date;
}
export interface Citation {
index: number;
url: string;
title: string;
domain: string;
snippet?: string;
favicon?: string;
}
export interface Widget {
type: WidgetType;
params: Record<string, unknown>;
}
export type WidgetType =
| 'sources'
| 'images'
| 'videos'
| 'products'
| 'profiles'
| 'promos'
| 'knowledge_card'
| 'image_gallery'
| 'video_embed'
| 'weather'
| 'finance'
| 'map';
export interface Chat {
id: string;
title: string;
messages: Message[];
createdAt: Date;
updatedAt: Date;
}
export interface DiscoverItem {
title: string;
content: string;
url: string;
thumbnail?: string;
sourcesCount?: number;
digestId?: string;
}
export interface Space {
id: string;
name: string;
description?: string;
instructions?: string;
focusMode?: FocusMode;
isPublic?: boolean;
createdAt: string;
updatedAt?: string;
}
export type FocusMode =
| 'all'
| 'academic'
| 'youtube'
| 'reddit'
| 'code'
| 'news'
| 'math'
| 'finance';
export interface Thread {
id: string;
userId: string;
spaceId?: string;
title: string;
focusMode: string;
isPublic: boolean;
shareId?: string;
messages?: ThreadMessage[];
createdAt: string;
updatedAt: string;
}
export interface ThreadMessage {
id: string;
threadId: string;
role: 'user' | 'assistant';
content: string;
sources?: ThreadSource[];
widgets?: Record<string, unknown>[];
relatedQuestions?: string[];
model?: string;
tokensUsed?: number;
createdAt: string;
}
export interface ThreadSource {
url: string;
title: string;
domain: string;
}
export interface SearchResult {
url: string;
title: string;
content: string;
domain?: string;
thumbnail?: string;
}
export interface StreamEvent {
type: string;
data?: unknown;
}
export interface ChatRequest {
message: {
messageId: string;
chatId: string;
content: string;
};
optimizationMode: 'speed' | 'balanced' | 'quality';
sources?: string[];
history?: [string, string][];
chatModel?: {
providerId: string;
key: string;
};
locale?: string;
}
export interface ApiConfig {
version: number;
setupComplete: boolean;
modelProviders: ModelProvider[];
}
export interface ModelProvider {
id: string;
name: string;
models: Model[];
}
export interface Model {
id: string;
name: string;
contextLength: number;
}
export interface FinanceStock {
symbol: string;
name: string;
price: number;
change: number;
changePercent: number;
volume?: number;
marketCap?: number;
sector?: string;
}
export interface HeatmapData {
market: string;
timeRange: string;
sectors: HeatmapSector[];
updatedAt: string;
}
export interface HeatmapSector {
name: string;
change: number;
tickers: FinanceStock[];
}
export interface TopMovers {
gainers: FinanceStock[];
losers: FinanceStock[];
mostActive: FinanceStock[];
}
export interface FinanceMarket {
id: string;
name: string;
region: string;
}
export interface Lesson {
id: string;
title: string;
topic: string;
difficulty: 'beginner' | 'intermediate' | 'advanced' | 'expert';
mode: 'explain' | 'guided' | 'interactive' | 'practice' | 'quiz';
stepsCount: number;
estimatedTime: string;
progress: LessonProgress;
createdAt: string;
}
export interface LessonProgress {
currentStep: number;
completedSteps: number[];
score: number;
timeSpent: number;
}
export interface LessonStep {
index: number;
title: string;
content: string;
type: 'explanation' | 'code' | 'visualization' | 'quiz' | 'practice';
codeExample?: CodeExample;
quiz?: QuizQuestion;
practice?: PracticeExercise;
completed: boolean;
}
export interface CodeExample {
language: string;
code: string;
explanation?: string;
runnable?: boolean;
}
export interface QuizQuestion {
question: string;
options: string[];
correctAnswers: string[];
explanation?: string;
multiSelect?: boolean;
}
export interface PracticeExercise {
task: string;
hints: string[];
solution: string;
language: string;
}
// =====================
// Computer Types (Perplexity Computer style)
// =====================
export type ComputerTaskStatus =
| 'pending'
| 'planning'
| 'executing'
| 'waiting_user'
| 'completed'
| 'failed'
| 'cancelled'
| 'scheduled'
| 'paused'
| 'checkpoint'
| 'long_running';
export type ComputerTaskType =
| 'research'
| 'code'
| 'analysis'
| 'design'
| 'deploy'
| 'monitor'
| 'report'
| 'communicate'
| 'schedule'
| 'transform'
| 'validate';
export type DurationMode = 'short' | 'medium' | 'long' | 'extended' | 'unlimited';
export type TaskPriority = 'low' | 'normal' | 'high' | 'critical';
export interface ComputerTask {
id: string;
userId: string;
query: string;
status: ComputerTaskStatus;
plan?: TaskPlan;
subTasks?: SubTask[];
artifacts?: Artifact[];
memory?: Record<string, unknown>;
progress: number;
message?: string;
error?: string;
schedule?: ComputerSchedule;
nextRunAt?: string;
runCount: number;
totalCost: number;
createdAt: string;
updatedAt: string;
completedAt?: string;
durationMode: DurationMode;
checkpoint?: Checkpoint;
checkpoints?: Checkpoint[];
maxDuration: number;
estimatedEnd?: string;
iterations: number;
maxIterations: number;
pausedAt?: string;
resumedAt?: string;
totalRuntime: number;
heartbeatAt?: string;
priority: TaskPriority;
resourceLimits?: ResourceLimits;
}
export interface TaskPlan {
query: string;
summary: string;
subTasks: SubTask[];
executionOrder: string[][];
estimatedCost: number;
estimatedTimeSeconds: number;
}
export interface SubTask {
id: string;
type: ComputerTaskType;
description: string;
dependencies?: string[];
modelId?: string;
requiredCaps?: string[];
input?: Record<string, unknown>;
output?: Record<string, unknown>;
status: ComputerTaskStatus;
progress: number;
error?: string;
cost: number;
startedAt?: string;
completedAt?: string;
retries: number;
maxRetries: number;
}
export interface Artifact {
id: string;
taskId: string;
type: 'file' | 'code' | 'report' | 'deployment' | 'image' | 'data';
name: string;
url?: string;
size: number;
mimeType?: string;
metadata?: Record<string, unknown>;
createdAt: string;
}
export interface ComputerSchedule {
type: 'once' | 'interval' | 'cron' | 'hourly' | 'daily' | 'weekly' | 'monthly';
cronExpr?: string;
intervalSeconds?: number;
nextRun: string;
maxRuns: number;
runCount: number;
expiresAt?: string;
enabled: boolean;
timezone?: string;
}
export interface Checkpoint {
id: string;
taskId: string;
subTaskIndex: number;
waveIndex: number;
state: Record<string, unknown>;
progress: number;
artifacts: string[];
memory: Record<string, unknown>;
createdAt: string;
runtimeSoFar: number;
costSoFar: number;
reason: string;
}
export interface ResourceLimits {
maxCpu: number;
maxMemoryMb: number;
maxDiskMb: number;
maxNetworkMbps: number;
maxCostPerHour: number;
maxTotalCost: number;
maxConcurrent: number;
idleTimeoutMins: number;
}
export interface ComputerTaskEvent {
type: string;
taskId: string;
subTaskId?: string;
status?: ComputerTaskStatus;
progress?: number;
message?: string;
data?: Record<string, unknown>;
timestamp: string;
}
export interface ComputerExecuteRequest {
query: string;
userId?: string;
options?: {
async?: boolean;
maxCost?: number;
timeoutSeconds?: number;
enableSandbox?: boolean;
schedule?: Partial<ComputerSchedule>;
context?: Record<string, unknown>;
durationMode?: DurationMode;
priority?: TaskPriority;
resourceLimits?: Partial<ResourceLimits>;
resumeFromId?: string;
enableBrowser?: boolean;
notifyOnEvents?: string[];
webhookUrl?: string;
tags?: string[];
};
}
export interface ComputerModel {
id: string;
provider: string;
model: string;
capabilities: string[];
costPer1K: number;
maxContext: number;
maxTokens: number;
priority: number;
description?: string;
}
export interface ComputerConnector {
id: string;
name: string;
description: string;
actions: string[];
enabled: boolean;
configRequired: string[];
}

View File

@@ -0,0 +1,54 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
export function formatRelativeTime(date: Date | string): string {
const now = new Date();
const target = typeof date === 'string' ? new Date(date) : date;
const diff = now.getTime() - target.getTime();
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (seconds < 60) return 'только что';
if (minutes < 60) return `${minutes} мин назад`;
if (hours < 24) return `${hours} ч назад`;
if (days < 7) return `${days} дн назад`;
return target.toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'short',
});
}
export function truncate(str: string, length: number): string {
if (str.length <= length) return str;
return str.slice(0, length) + '...';
}
export function getDomain(url: string): string {
try {
return new URL(url).hostname.replace('www.', '');
} catch {
return url;
}
}
export function generateId(): string {
return Math.random().toString(36).substring(2, 15);
}
export function debounce<T extends (...args: Parameters<T>) => ReturnType<T>>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timeoutId: NodeJS.Timeout;
return (...args: Parameters<T>) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delay);
};
}

View File

@@ -0,0 +1,165 @@
import type { Config } from 'tailwindcss';
const config: Config = {
content: [
'./src/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
/* Cursor IDE 2026 Color Palette */
base: 'hsl(var(--bg-base))',
elevated: 'hsl(var(--bg-elevated))',
surface: 'hsl(var(--bg-surface))',
overlay: 'hsl(var(--bg-overlay))',
/* Text colors */
primary: 'hsl(var(--text-primary))',
secondary: 'hsl(var(--text-secondary))',
muted: 'hsl(var(--text-muted))',
faint: 'hsl(var(--text-faint))',
/* Accent colors */
accent: {
DEFAULT: 'hsl(var(--accent))',
hover: 'hsl(var(--accent-hover))',
muted: 'hsl(var(--accent-muted))',
subtle: 'hsl(var(--accent-subtle))',
foreground: 'hsl(0 0% 100%)',
},
'accent-secondary': {
DEFAULT: 'hsl(var(--accent-secondary))',
muted: 'hsl(var(--accent-secondary-muted))',
},
/* Semantic colors */
success: {
DEFAULT: 'hsl(var(--success))',
muted: 'hsl(var(--success-muted))',
},
warning: {
DEFAULT: 'hsl(var(--warning))',
muted: 'hsl(var(--warning-muted))',
},
error: {
DEFAULT: 'hsl(var(--error))',
muted: 'hsl(var(--error-muted))',
},
/* Border colors */
border: {
DEFAULT: 'hsl(var(--border))',
hover: 'hsl(var(--border-hover))',
focus: 'hsl(var(--border-focus))',
},
/* Legacy mappings */
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
},
borderRadius: {
'2xl': '1rem',
'3xl': '1.5rem',
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
mono: ['JetBrains Mono', 'Fira Code', 'SF Mono', 'Consolas', 'monospace'],
},
fontSize: {
'2xs': ['0.625rem', { lineHeight: '0.875rem' }],
'xs': ['0.75rem', { lineHeight: '1rem' }],
'sm': ['0.875rem', { lineHeight: '1.25rem' }],
'base': ['0.9375rem', { lineHeight: '1.5rem' }],
'lg': ['1.0625rem', { lineHeight: '1.625rem' }],
},
spacing: {
'18': '4.5rem',
'22': '5.5rem',
'26': '6.5rem',
'30': '7.5rem',
},
backdropBlur: {
xs: '2px',
},
boxShadow: {
'glow-sm': '0 0 10px hsl(239 84% 67% / 0.1)',
'glow-md': '0 0 20px hsl(239 84% 67% / 0.15)',
'glow-lg': '0 0 40px hsl(239 84% 67% / 0.2)',
'inner-glow': 'inset 0 0 20px hsl(240 6% 12% / 0.5)',
'elevated': '0 4px 20px hsl(240 6% 4% / 0.4), 0 0 1px hsl(240 5% 20% / 0.5)',
'card': '0 2px 8px hsl(240 6% 4% / 0.3)',
'dropdown': '0 8px 32px hsl(240 6% 4% / 0.5), 0 0 1px hsl(240 5% 20% / 0.5)',
},
keyframes: {
'fade-in': {
'0%': { opacity: '0', transform: 'translateY(4px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
'fade-in-up': {
'0%': { opacity: '0', transform: 'translateY(12px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
'slide-in-right': {
'0%': { opacity: '0', transform: 'translateX(-12px)' },
'100%': { opacity: '1', transform: 'translateX(0)' },
},
'slide-in-left': {
'0%': { opacity: '0', transform: 'translateX(12px)' },
'100%': { opacity: '1', transform: 'translateX(0)' },
},
'scale-in': {
'0%': { opacity: '0', transform: 'scale(0.95)' },
'100%': { opacity: '1', transform: 'scale(1)' },
},
'pulse-soft': {
'0%, 100%': { opacity: '1' },
'50%': { opacity: '0.7' },
},
shimmer: {
'0%': { backgroundPosition: '-200% 0' },
'100%': { backgroundPosition: '200% 0' },
},
'glow-pulse': {
'0%, 100%': { boxShadow: '0 0 20px hsl(239 84% 67% / 0.15)' },
'50%': { boxShadow: '0 0 30px hsl(239 84% 67% / 0.25)' },
},
'border-pulse': {
'0%, 100%': { borderColor: 'hsl(239 84% 67% / 0.3)' },
'50%': { borderColor: 'hsl(239 84% 67% / 0.5)' },
},
},
animation: {
'fade-in': 'fade-in 0.2s ease-out forwards',
'fade-in-up': 'fade-in-up 0.3s ease-out forwards',
'slide-in-right': 'slide-in-right 0.25s ease-out forwards',
'slide-in-left': 'slide-in-left 0.25s ease-out forwards',
'scale-in': 'scale-in 0.2s ease-out forwards',
'pulse-soft': 'pulse-soft 2s ease-in-out infinite',
shimmer: 'shimmer 1.5s linear infinite',
'glow-pulse': 'glow-pulse 2s ease-in-out infinite',
'border-pulse': 'border-pulse 2s ease-in-out infinite',
},
},
},
plugins: [],
};
export default config;

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}