feat: auth service + security audit fixes + cleanup legacy services

Major changes:
- Add auth-svc: JWT auth, register/login/refresh, password reset
- Add auth UI: modals, pages (/login, /register, /forgot-password)
- Add usage tracking (usage_metrics table, daily limits)
- Add tiered rate limiting (free/pro/business)
- Add LLM usage limits per tier

Security fixes:
- All repos now require userID for Update/Delete operations
- JWT middleware in chat-svc, llm-svc, agent-svc, discover-svc
- ErrNotFound/ErrForbidden errors for proper access control

Cleanup:
- Remove legacy TypeScript services/ directory
- Remove computer-svc (to be reimplemented)
- Remove old deploy/docker configs

New files:
- backend/cmd/auth-svc/main.go
- backend/internal/auth/{types,repository}.go
- backend/internal/usage/{types,repository}.go
- backend/pkg/middleware/{llm_limits,ratelimit_tiered}.go
- backend/webui/src/components/auth/*
- backend/webui/src/app/(auth)/*

Made-with: Cursor
This commit is contained in:
home
2026-02-28 01:33:49 +03:00
parent 120fbbaafb
commit a0e3748dde
523 changed files with 10776 additions and 59630 deletions

View File

@@ -0,0 +1,225 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import {
Shield,
User,
FileText,
Settings,
Compass,
Clock,
} from 'lucide-react';
import { fetchAuditLogs } from '@/lib/api';
import type { AuditLog } from '@/lib/types';
const actionIcons: Record<string, React.ElementType> = {
create: FileText,
update: Settings,
delete: Shield,
publish: Compass,
reorder: Settings,
};
const actionLabels: Record<string, string> = {
create: 'Создание',
update: 'Изменение',
delete: 'Удаление',
publish: 'Публикация',
reorder: 'Сортировка',
};
const resourceLabels: Record<string, string> = {
user: 'Пользователь',
post: 'Пост',
settings: 'Настройки',
discover_category: 'Категория Discover',
discover_categories: 'Категории Discover',
discover_source: 'Источник Discover',
};
export default function AdminAuditPage() {
const [logs, setLogs] = useState<AuditLog[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [actionFilter, setActionFilter] = useState('');
const [resourceFilter, setResourceFilter] = useState('');
const [loading, setLoading] = useState(true);
const loadLogs = useCallback(async () => {
setLoading(true);
try {
const data = await fetchAuditLogs(
page,
50,
actionFilter || undefined,
resourceFilter || undefined
);
setLogs(data.logs);
setTotal(data.total);
} catch (err) {
console.error('Failed to load audit logs:', err);
} finally {
setLoading(false);
}
}, [page, actionFilter, resourceFilter]);
useEffect(() => {
loadLogs();
}, [loadLogs]);
const totalPages = Math.ceil(total / 50);
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const getActionColor = (action: string) => {
switch (action) {
case 'create':
return 'bg-green-500/10 text-green-400';
case 'update':
return 'bg-blue-500/10 text-blue-400';
case 'delete':
return 'bg-red-500/10 text-red-400';
case 'publish':
return 'bg-purple-500/10 text-purple-400';
default:
return 'bg-gray-500/10 text-gray-400';
}
};
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-primary">Аудит</h1>
<p className="text-muted">История действий администраторов</p>
</div>
<div className="bg-surface rounded-xl border border-border/30">
<div className="p-4 border-b border-border/30 flex gap-4">
<select
value={actionFilter}
onChange={(e) => {
setActionFilter(e.target.value);
setPage(1);
}}
className="px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
>
<option value="">Все действия</option>
<option value="create">Создание</option>
<option value="update">Изменение</option>
<option value="delete">Удаление</option>
<option value="publish">Публикация</option>
</select>
<select
value={resourceFilter}
onChange={(e) => {
setResourceFilter(e.target.value);
setPage(1);
}}
className="px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
>
<option value="">Все ресурсы</option>
<option value="user">Пользователи</option>
<option value="post">Посты</option>
<option value="settings">Настройки</option>
<option value="discover_category">Категории Discover</option>
<option value="discover_source">Источники Discover</option>
</select>
</div>
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
) : logs.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-muted">
<Shield className="w-12 h-12 mb-4 opacity-50" />
<p>Записей аудита нет</p>
</div>
) : (
<>
<div className="divide-y divide-border/20">
{logs.map((log) => {
const ActionIcon = actionIcons[log.action] || Shield;
return (
<div key={log.id} className="px-4 py-3 hover:bg-base/50">
<div className="flex items-start gap-4">
<div className={`p-2 rounded-lg ${getActionColor(log.action)}`}>
<ActionIcon className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className={`px-2 py-0.5 rounded text-xs ${getActionColor(log.action)}`}>
{actionLabels[log.action] || log.action}
</span>
<span className="text-secondary">
{resourceLabels[log.resource] || log.resource}
</span>
{log.resourceId && (
<span className="text-xs text-muted font-mono">
{log.resourceId.slice(0, 8)}...
</span>
)}
</div>
<div className="flex items-center gap-4 text-sm">
<span className="flex items-center gap-1 text-muted">
<User className="w-3 h-3" />
{log.userEmail}
</span>
<span className="flex items-center gap-1 text-muted">
<Clock className="w-3 h-3" />
{formatDate(log.createdAt)}
</span>
{log.ipAddress && (
<span className="text-xs text-muted">
IP: {log.ipAddress}
</span>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-border/30">
<p className="text-sm text-muted">
Показано {logs.length} из {total}
</p>
<div className="flex gap-2">
<button
onClick={() => setPage(Math.max(1, page - 1))}
disabled={page === 1}
className="px-3 py-1 border border-border/50 rounded text-sm disabled:opacity-50"
>
Назад
</button>
<span className="px-3 py-1 text-sm text-muted">
{page} / {totalPages}
</span>
<button
onClick={() => setPage(Math.min(totalPages, page + 1))}
disabled={page === totalPages}
className="px-3 py-1 border border-border/50 rounded text-sm disabled:opacity-50"
>
Вперёд
</button>
</div>
</div>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,513 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import {
Plus,
Trash2,
GripVertical,
ExternalLink,
ToggleLeft,
ToggleRight,
} from 'lucide-react';
import {
fetchDiscoverCategories,
createDiscoverCategory,
updateDiscoverCategory,
deleteDiscoverCategory,
reorderDiscoverCategories,
fetchDiscoverSources,
createDiscoverSource,
deleteDiscoverSource,
} from '@/lib/api';
import type { DiscoverCategory, DiscoverSource } from '@/lib/types';
interface CategoryModalProps {
category?: DiscoverCategory;
onClose: () => void;
onSave: (data: { name: string; nameRu: string; icon: string; color: string; keywords: string[]; regions: string[] }) => void;
}
function CategoryModal({ category, onClose, onSave }: CategoryModalProps) {
const [name, setName] = useState(category?.name || '');
const [nameRu, setNameRu] = useState(category?.nameRu || '');
const [icon, setIcon] = useState(category?.icon || '📰');
const [color, setColor] = useState(category?.color || '#6B7280');
const [keywords, setKeywords] = useState(category?.keywords?.join(', ') || '');
const [regions, setRegions] = useState(category?.regions?.join(', ') || 'world, russia');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave({
name,
nameRu,
icon,
color,
keywords: keywords.split(',').map(k => k.trim()).filter(Boolean),
regions: regions.split(',').map(r => r.trim()).filter(Boolean),
});
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-surface rounded-xl p-6 w-full max-w-md">
<h2 className="text-xl font-bold text-primary mb-4">
{category ? 'Редактировать категорию' : 'Новая категория'}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-secondary mb-1">ID (англ.)</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="tech"
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
required
/>
</div>
<div>
<label className="block text-sm text-secondary mb-1">Название (рус.)</label>
<input
type="text"
value={nameRu}
onChange={(e) => setNameRu(e.target.value)}
placeholder="Технологии"
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
required
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-secondary mb-1">Иконка</label>
<input
type="text"
value={icon}
onChange={(e) => setIcon(e.target.value)}
placeholder="💻"
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary text-center text-2xl"
required
/>
</div>
<div>
<label className="block text-sm text-secondary mb-1">Цвет</label>
<div className="flex gap-2">
<input
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
className="w-12 h-10 bg-base border border-border/50 rounded-lg cursor-pointer"
/>
<input
type="text"
value={color}
onChange={(e) => setColor(e.target.value)}
className="flex-1 px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary font-mono"
/>
</div>
</div>
</div>
<div>
<label className="block text-sm text-secondary mb-1">Ключевые слова (через запятую)</label>
<input
type="text"
value={keywords}
onChange={(e) => setKeywords(e.target.value)}
placeholder="technology, AI, software"
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
/>
</div>
<div>
<label className="block text-sm text-secondary mb-1">Регионы (через запятую)</label>
<input
type="text"
value={regions}
onChange={(e) => setRegions(e.target.value)}
placeholder="world, russia, eu"
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
/>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2 border border-border/50 rounded-lg text-secondary hover:bg-base transition-colors"
>
Отмена
</button>
<button
type="submit"
className="flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
>
Сохранить
</button>
</div>
</form>
</div>
</div>
);
}
interface SourceModalProps {
onClose: () => void;
onSave: (data: { name: string; url: string; logoUrl?: string; categories: string[]; trustScore: number; description?: string }) => void;
}
function SourceModal({ onClose, onSave }: SourceModalProps) {
const [name, setName] = useState('');
const [url, setUrl] = useState('');
const [logoUrl, setLogoUrl] = useState('');
const [categories, setCategories] = useState('');
const [trustScore, setTrustScore] = useState(0.5);
const [description, setDescription] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave({
name,
url,
logoUrl: logoUrl || undefined,
categories: categories.split(',').map(c => c.trim()).filter(Boolean),
trustScore,
description: description || undefined,
});
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-surface rounded-xl p-6 w-full max-w-md">
<h2 className="text-xl font-bold text-primary mb-4">Новый источник</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm text-secondary mb-1">Название</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Habr"
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
required
/>
</div>
<div>
<label className="block text-sm text-secondary mb-1">URL</label>
<input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://habr.com"
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
required
/>
</div>
<div>
<label className="block text-sm text-secondary mb-1">URL логотипа</label>
<input
type="url"
value={logoUrl}
onChange={(e) => setLogoUrl(e.target.value)}
placeholder="https://..."
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
/>
</div>
<div>
<label className="block text-sm text-secondary mb-1">Категории (через запятую)</label>
<input
type="text"
value={categories}
onChange={(e) => setCategories(e.target.value)}
placeholder="tech, science"
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
/>
</div>
<div>
<label className="block text-sm text-secondary mb-1">
Уровень доверия: {trustScore.toFixed(2)}
</label>
<input
type="range"
min="0"
max="1"
step="0.01"
value={trustScore}
onChange={(e) => setTrustScore(parseFloat(e.target.value))}
className="w-full"
/>
</div>
<div>
<label className="block text-sm text-secondary mb-1">Описание</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary resize-none"
/>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2 border border-border/50 rounded-lg text-secondary hover:bg-base transition-colors"
>
Отмена
</button>
<button
type="submit"
className="flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
>
Добавить
</button>
</div>
</form>
</div>
</div>
);
}
export default function AdminDiscoverPage() {
const [categories, setCategories] = useState<DiscoverCategory[]>([]);
const [sources, setSources] = useState<DiscoverSource[]>([]);
const [loading, setLoading] = useState(true);
const [categoryModal, setCategoryModal] = useState<DiscoverCategory | null | undefined>(undefined);
const [showSourceModal, setShowSourceModal] = useState(false);
const [activeTab, setActiveTab] = useState<'categories' | 'sources'>('categories');
const loadData = useCallback(async () => {
setLoading(true);
try {
const [catData, srcData] = await Promise.all([
fetchDiscoverCategories(),
fetchDiscoverSources(),
]);
setCategories(catData.categories || []);
setSources(srcData.sources || []);
} catch (err) {
console.error('Failed to load discover config:', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadData();
}, [loadData]);
const handleSaveCategory = async (data: { name: string; nameRu: string; icon: string; color: string; keywords: string[]; regions: string[] }) => {
try {
if (categoryModal) {
await updateDiscoverCategory(categoryModal.id, data);
} else {
await createDiscoverCategory(data);
}
setCategoryModal(undefined);
loadData();
} catch (err) {
console.error('Failed to save category:', err);
}
};
const handleDeleteCategory = async (id: string) => {
if (!confirm('Удалить категорию?')) return;
try {
await deleteDiscoverCategory(id);
loadData();
} catch (err) {
console.error('Failed to delete category:', err);
}
};
const handleToggleCategory = async (cat: DiscoverCategory) => {
try {
await updateDiscoverCategory(cat.id, { isActive: !cat.isActive });
loadData();
} catch (err) {
console.error('Failed to toggle category:', err);
}
};
const handleSaveSource = async (data: { name: string; url: string; logoUrl?: string; categories: string[]; trustScore: number; description?: string }) => {
try {
await createDiscoverSource(data);
setShowSourceModal(false);
loadData();
} catch (err) {
console.error('Failed to save source:', err);
}
};
const handleDeleteSource = async (id: string) => {
if (!confirm('Удалить источник?')) return;
try {
await deleteDiscoverSource(id);
loadData();
} catch (err) {
console.error('Failed to delete source:', err);
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-primary">Discover</h1>
<p className="text-muted">Управление категориями и источниками новостей</p>
</div>
<div className="flex gap-2 border-b border-border/30">
<button
onClick={() => setActiveTab('categories')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'categories'
? 'border-primary text-primary'
: 'border-transparent text-muted hover:text-secondary'
}`}
>
Категории ({categories.length})
</button>
<button
onClick={() => setActiveTab('sources')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'sources'
? 'border-primary text-primary'
: 'border-transparent text-muted hover:text-secondary'
}`}
>
Источники ({sources.length})
</button>
</div>
{activeTab === 'categories' && (
<div className="bg-surface rounded-xl border border-border/30">
<div className="p-4 border-b border-border/30 flex items-center justify-between">
<p className="text-sm text-muted">Перетащите для изменения порядка</p>
<button
onClick={() => setCategoryModal(null)}
className="flex items-center gap-2 px-3 py-1.5 bg-primary text-white rounded-lg text-sm hover:bg-primary/90 transition-colors"
>
<Plus className="w-4 h-4" />
Добавить
</button>
</div>
<div className="divide-y divide-border/20">
{categories.map((cat) => (
<div key={cat.id} className="flex items-center gap-4 px-4 py-3 hover:bg-base/50">
<GripVertical className="w-4 h-4 text-muted cursor-grab" />
<div
className="w-10 h-10 rounded-lg flex items-center justify-center text-xl"
style={{ backgroundColor: cat.color + '20' }}
>
{cat.icon}
</div>
<div className="flex-1">
<p className="text-primary font-medium">{cat.nameRu}</p>
<p className="text-xs text-muted">{cat.name} {cat.keywords.join(', ')}</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleToggleCategory(cat)}
className={`p-1 rounded ${cat.isActive ? 'text-green-400' : 'text-muted'}`}
>
{cat.isActive ? <ToggleRight className="w-6 h-6" /> : <ToggleLeft className="w-6 h-6" />}
</button>
<button
onClick={() => setCategoryModal(cat)}
className="p-1 text-muted hover:text-primary rounded"
>
Изменить
</button>
<button
onClick={() => handleDeleteCategory(cat.id)}
className="p-1 text-muted hover:text-red-400 rounded"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
</div>
)}
{activeTab === 'sources' && (
<div className="bg-surface rounded-xl border border-border/30">
<div className="p-4 border-b border-border/30 flex items-center justify-between">
<p className="text-sm text-muted">Доверенные источники новостей</p>
<button
onClick={() => setShowSourceModal(true)}
className="flex items-center gap-2 px-3 py-1.5 bg-primary text-white rounded-lg text-sm hover:bg-primary/90 transition-colors"
>
<Plus className="w-4 h-4" />
Добавить
</button>
</div>
{sources.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 text-muted">
<ExternalLink className="w-12 h-12 mb-4 opacity-50" />
<p>Источников пока нет</p>
</div>
) : (
<div className="divide-y divide-border/20">
{sources.map((source) => (
<div key={source.id} className="flex items-center gap-4 px-4 py-3 hover:bg-base/50">
<div className="w-10 h-10 rounded-lg bg-base flex items-center justify-center">
{source.logoUrl ? (
<img src={source.logoUrl} alt="" className="w-6 h-6" />
) : (
<ExternalLink className="w-5 h-5 text-muted" />
)}
</div>
<div className="flex-1">
<p className="text-primary font-medium">{source.name}</p>
<p className="text-xs text-muted">{source.url}</p>
</div>
<div className="flex items-center gap-4">
<div className="text-sm">
<span className="text-muted">Доверие:</span>
<span className={`ml-1 font-medium ${
source.trustScore >= 0.7 ? 'text-green-400' :
source.trustScore >= 0.4 ? 'text-yellow-400' : 'text-red-400'
}`}>
{(source.trustScore * 100).toFixed(0)}%
</span>
</div>
<button
onClick={() => handleDeleteSource(source.id)}
className="p-1 text-muted hover:text-red-400 rounded"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
)}
{categoryModal !== undefined && (
<CategoryModal
category={categoryModal || undefined}
onClose={() => setCategoryModal(undefined)}
onSave={handleSaveCategory}
/>
)}
{showSourceModal && (
<SourceModal
onClose={() => setShowSourceModal(false)}
onSave={handleSaveSource}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,125 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import {
LayoutDashboard,
Users,
FileText,
Compass,
Settings,
Shield,
ArrowLeft,
} from 'lucide-react';
interface AdminLayoutProps {
children: React.ReactNode;
}
const adminNavItems = [
{ href: '/admin', icon: LayoutDashboard, label: 'Дашборд' },
{ href: '/admin/users', icon: Users, label: 'Пользователи' },
{ href: '/admin/posts', icon: FileText, label: 'Посты' },
{ href: '/admin/discover', icon: Compass, label: 'Discover' },
{ href: '/admin/settings', icon: Settings, label: 'Настройки' },
{ href: '/admin/audit', icon: Shield, label: 'Аудит' },
];
export default function AdminLayout({ children }: AdminLayoutProps) {
const router = useRouter();
const pathname = usePathname();
const [isAdmin, setIsAdmin] = useState<boolean | null>(null);
useEffect(() => {
const token = localStorage.getItem('token');
if (!token) {
router.push('/');
return;
}
try {
const payload = JSON.parse(atob(token.split('.')[1]));
if (payload.role !== 'admin') {
router.push('/');
return;
}
setIsAdmin(true);
} catch {
router.push('/');
}
}, [router]);
if (isAdmin === null) {
return (
<div className="flex items-center justify-center h-screen">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
if (!isAdmin) {
return null;
}
return (
<div className="flex h-full">
<aside className="w-64 bg-surface border-r border-border/50 flex flex-col">
<div className="p-4 border-b border-border/30">
<Link
href="/"
className="flex items-center gap-2 text-muted hover:text-primary transition-colors"
>
<ArrowLeft className="w-4 h-4" />
<span className="text-sm">Вернуться на сайт</span>
</Link>
</div>
<div className="p-4">
<h1 className="text-lg font-bold text-primary flex items-center gap-2">
<Shield className="w-5 h-5" />
Админ-панель
</h1>
</div>
<nav className="flex-1 px-3 py-2 space-y-1">
{adminNavItems.map((item) => {
const isActive = pathname === item.href ||
(item.href !== '/admin' && pathname.startsWith(item.href));
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
className={`
flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all
${isActive
? 'bg-primary/10 text-primary'
: 'text-secondary hover:text-primary hover:bg-surface/80'
}
`}
>
<Icon className="w-[18px] h-[18px]" />
<span className="text-sm font-medium">{item.label}</span>
</Link>
);
})}
</nav>
<div className="p-4 border-t border-border/30">
<div className="text-xs text-muted">
GooSeek Admin v1.0
</div>
</div>
</aside>
<main className="flex-1 overflow-auto bg-base">
<div className="p-6">
{children}
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,180 @@
'use client';
import { useEffect, useState } from 'react';
import {
Users,
FileText,
Search,
HardDrive,
TrendingUp,
Activity,
} from 'lucide-react';
import { fetchAdminDashboard } from '@/lib/api';
import type { DashboardStats } from '@/lib/types';
interface StatCardProps {
title: string;
value: number | string;
icon: React.ElementType;
subValue?: string;
color: string;
}
function StatCard({ title, value, icon: Icon, subValue, color }: StatCardProps) {
return (
<div className="bg-surface rounded-xl p-6 border border-border/30">
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-muted mb-1">{title}</p>
<p className="text-3xl font-bold text-primary">{value}</p>
{subValue && (
<p className="text-xs text-muted mt-1">{subValue}</p>
)}
</div>
<div className={`p-3 rounded-lg ${color}`}>
<Icon className="w-6 h-6 text-white" />
</div>
</div>
</div>
);
}
export default function AdminDashboardPage() {
const [stats, setStats] = useState<DashboardStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function loadStats() {
try {
const data = await fetchAdminDashboard();
setStats(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Ошибка загрузки');
} finally {
setLoading(false);
}
}
loadStats();
}, []);
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
if (error) {
return (
<div className="bg-red-500/10 text-red-400 p-4 rounded-lg">
{error}
</div>
);
}
const storagePercent = stats
? Math.round((stats.storageUsedMb / stats.storageLimitMb) * 100)
: 0;
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-primary">Дашборд</h1>
<p className="text-muted">Обзор платформы GooSeek</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
title="Всего пользователей"
value={stats?.totalUsers || 0}
icon={Users}
subValue={`${stats?.activeUsers || 0} активных`}
color="bg-blue-500"
/>
<StatCard
title="Посты"
value={stats?.totalPosts || 0}
icon={FileText}
subValue={`${stats?.publishedPosts || 0} опубликовано`}
color="bg-green-500"
/>
<StatCard
title="Поисков сегодня"
value={stats?.todaySearches || 0}
icon={Search}
subValue={`${stats?.totalSearches || 0} всего`}
color="bg-purple-500"
/>
<StatCard
title="Хранилище"
value={`${stats?.storageUsedMb || 0} МБ`}
icon={HardDrive}
subValue={`${storagePercent}% использовано`}
color="bg-orange-500"
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-surface rounded-xl p-6 border border-border/30">
<div className="flex items-center gap-2 mb-4">
<TrendingUp className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold text-primary">Активность</h2>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-secondary">Регистрации за неделю</span>
<span className="text-primary font-medium"></span>
</div>
<div className="flex items-center justify-between">
<span className="text-secondary">Новые посты за неделю</span>
<span className="text-primary font-medium"></span>
</div>
<div className="flex items-center justify-between">
<span className="text-secondary">Поисков за неделю</span>
<span className="text-primary font-medium"></span>
</div>
</div>
</div>
<div className="bg-surface rounded-xl p-6 border border-border/30">
<div className="flex items-center gap-2 mb-4">
<Activity className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold text-primary">Статус сервисов</h2>
</div>
<div className="space-y-3">
{['API Gateway', 'Chat Service', 'Search Service', 'LLM Service', 'Discover Service'].map((service) => (
<div key={service} className="flex items-center justify-between">
<span className="text-secondary">{service}</span>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500" />
<span className="text-xs text-muted">Online</span>
</div>
</div>
))}
</div>
</div>
</div>
<div className="bg-surface rounded-xl p-6 border border-border/30">
<h2 className="text-lg font-semibold text-primary mb-4">Использование хранилища</h2>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-secondary">
{stats?.storageUsedMb || 0} МБ из {stats?.storageLimitMb || 0} МБ
</span>
<span className="text-muted">{storagePercent}%</span>
</div>
<div className="w-full bg-border/30 rounded-full h-3">
<div
className="bg-gradient-to-r from-blue-500 to-purple-500 h-3 rounded-full transition-all"
style={{ width: `${Math.min(storagePercent, 100)}%` }}
/>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,415 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import {
Plus,
MoreVertical,
Edit,
Trash2,
Eye,
Send,
FileText,
} from 'lucide-react';
import {
fetchAdminPosts,
createAdminPost,
updateAdminPost,
deleteAdminPost,
publishAdminPost,
} from '@/lib/api';
import type { AdminPost } from '@/lib/types';
interface PostModalProps {
post?: AdminPost;
onClose: () => void;
onSave: (data: { title: string; content: string; excerpt?: string; category: string; tags?: string[]; status?: string }) => void;
}
function PostModal({ post, onClose, onSave }: PostModalProps) {
const [title, setTitle] = useState(post?.title || '');
const [content, setContent] = useState(post?.content || '');
const [excerpt, setExcerpt] = useState(post?.excerpt || '');
const [category, setCategory] = useState(post?.category || 'general');
const [tags, setTags] = useState(post?.tags?.join(', ') || '');
const [status, setStatus] = useState(post?.status || 'draft');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave({
title,
content,
excerpt: excerpt || undefined,
category,
tags: tags.split(',').map(t => t.trim()).filter(Boolean),
status,
});
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-surface rounded-xl p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<h2 className="text-xl font-bold text-primary mb-4">
{post ? 'Редактировать пост' : 'Новый пост'}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm text-secondary mb-1">Заголовок</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
required
/>
</div>
<div>
<label className="block text-sm text-secondary mb-1">Краткое описание</label>
<textarea
value={excerpt}
onChange={(e) => setExcerpt(e.target.value)}
rows={2}
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary resize-none"
/>
</div>
<div>
<label className="block text-sm text-secondary mb-1">Содержимое</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
rows={10}
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary resize-none font-mono text-sm"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-secondary mb-1">Категория</label>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
>
<option value="general">Общее</option>
<option value="news">Новости</option>
<option value="tutorial">Туториалы</option>
<option value="announcement">Объявления</option>
<option value="blog">Блог</option>
</select>
</div>
<div>
<label className="block text-sm text-secondary mb-1">Статус</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value)}
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
>
<option value="draft">Черновик</option>
<option value="published">Опубликован</option>
<option value="archived">Архив</option>
</select>
</div>
</div>
<div>
<label className="block text-sm text-secondary mb-1">Теги (через запятую)</label>
<input
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="ai, новости, обновление"
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
/>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2 border border-border/50 rounded-lg text-secondary hover:bg-base transition-colors"
>
Отмена
</button>
<button
type="submit"
className="flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
>
Сохранить
</button>
</div>
</form>
</div>
</div>
);
}
export default function AdminPostsPage() {
const [posts, setPosts] = useState<AdminPost[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [statusFilter, setStatusFilter] = useState('');
const [loading, setLoading] = useState(true);
const [modalPost, setModalPost] = useState<AdminPost | null | undefined>(undefined);
const [activeMenu, setActiveMenu] = useState<string | null>(null);
const loadPosts = useCallback(async () => {
setLoading(true);
try {
const data = await fetchAdminPosts(page, 20, statusFilter || undefined);
setPosts(data.posts);
setTotal(data.total);
} catch (err) {
console.error('Failed to load posts:', err);
} finally {
setLoading(false);
}
}, [page, statusFilter]);
useEffect(() => {
loadPosts();
}, [loadPosts]);
const handleSave = async (data: { title: string; content: string; excerpt?: string; category: string; tags?: string[]; status?: string }) => {
try {
if (modalPost) {
await updateAdminPost(modalPost.id, data);
} else {
await createAdminPost(data);
}
setModalPost(undefined);
loadPosts();
} catch (err) {
console.error('Failed to save post:', err);
}
};
const handleDelete = async (id: string) => {
if (!confirm('Удалить пост?')) return;
try {
await deleteAdminPost(id);
loadPosts();
} catch (err) {
console.error('Failed to delete post:', err);
}
};
const handlePublish = async (id: string) => {
try {
await publishAdminPost(id);
loadPosts();
} catch (err) {
console.error('Failed to publish post:', err);
}
};
const totalPages = Math.ceil(total / 20);
const getStatusBadge = (status: string) => {
switch (status) {
case 'published':
return 'bg-green-500/10 text-green-400';
case 'draft':
return 'bg-yellow-500/10 text-yellow-400';
case 'archived':
return 'bg-gray-500/10 text-gray-400';
default:
return 'bg-gray-500/10 text-gray-400';
}
};
const getStatusLabel = (status: string) => {
switch (status) {
case 'published':
return 'Опубликован';
case 'draft':
return 'Черновик';
case 'archived':
return 'Архив';
default:
return status;
}
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-primary">Посты</h1>
<p className="text-muted">Управление контентом платформы</p>
</div>
<button
onClick={() => setModalPost(null)}
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
>
<Plus className="w-4 h-4" />
Создать
</button>
</div>
<div className="bg-surface rounded-xl border border-border/30">
<div className="p-4 border-b border-border/30 flex gap-4">
<select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value);
setPage(1);
}}
className="px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
>
<option value="">Все статусы</option>
<option value="draft">Черновики</option>
<option value="published">Опубликованные</option>
<option value="archived">Архив</option>
</select>
</div>
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
) : posts.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-muted">
<FileText className="w-12 h-12 mb-4 opacity-50" />
<p>Постов пока нет</p>
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border/30">
<th className="text-left px-4 py-3 text-sm font-medium text-muted">Заголовок</th>
<th className="text-left px-4 py-3 text-sm font-medium text-muted">Категория</th>
<th className="text-left px-4 py-3 text-sm font-medium text-muted">Статус</th>
<th className="text-left px-4 py-3 text-sm font-medium text-muted">Автор</th>
<th className="text-left px-4 py-3 text-sm font-medium text-muted">Просмотры</th>
<th className="text-left px-4 py-3 text-sm font-medium text-muted">Дата</th>
<th className="w-12"></th>
</tr>
</thead>
<tbody>
{posts.map((post) => (
<tr key={post.id} className="border-b border-border/20 hover:bg-base/50">
<td className="px-4 py-3">
<div>
<p className="text-primary font-medium line-clamp-1">{post.title}</p>
{post.excerpt && (
<p className="text-xs text-muted line-clamp-1 mt-0.5">{post.excerpt}</p>
)}
</div>
</td>
<td className="px-4 py-3 text-sm text-secondary capitalize">
{post.category}
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded-full text-xs ${getStatusBadge(post.status)}`}>
{getStatusLabel(post.status)}
</span>
</td>
<td className="px-4 py-3 text-sm text-secondary">
{post.authorName || '—'}
</td>
<td className="px-4 py-3 text-sm text-muted">
{post.viewCount}
</td>
<td className="px-4 py-3 text-sm text-muted">
{new Date(post.createdAt).toLocaleDateString('ru-RU')}
</td>
<td className="px-4 py-3">
<div className="relative">
<button
onClick={() => setActiveMenu(activeMenu === post.id ? null : post.id)}
className="p-1 hover:bg-base rounded"
>
<MoreVertical className="w-4 h-4 text-muted" />
</button>
{activeMenu === post.id && (
<div className="absolute right-0 top-full mt-1 bg-surface border border-border/50 rounded-lg shadow-lg z-10 min-w-[150px]">
<button
onClick={() => {
setModalPost(post);
setActiveMenu(null);
}}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-secondary hover:bg-base"
>
<Edit className="w-4 h-4" />
Редактировать
</button>
{post.status === 'draft' && (
<button
onClick={() => {
handlePublish(post.id);
setActiveMenu(null);
}}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-green-400 hover:bg-base"
>
<Send className="w-4 h-4" />
Опубликовать
</button>
)}
<a
href={`/blog/${post.slug}`}
target="_blank"
rel="noopener noreferrer"
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-secondary hover:bg-base"
>
<Eye className="w-4 h-4" />
Просмотр
</a>
<button
onClick={() => {
handleDelete(post.id);
setActiveMenu(null);
}}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-400 hover:bg-base"
>
<Trash2 className="w-4 h-4" />
Удалить
</button>
</div>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-border/30">
<p className="text-sm text-muted">
Показано {posts.length} из {total}
</p>
<div className="flex gap-2">
<button
onClick={() => setPage(Math.max(1, page - 1))}
disabled={page === 1}
className="px-3 py-1 border border-border/50 rounded text-sm disabled:opacity-50"
>
Назад
</button>
<span className="px-3 py-1 text-sm text-muted">
{page} / {totalPages}
</span>
<button
onClick={() => setPage(Math.min(totalPages, page + 1))}
disabled={page === totalPages}
className="px-3 py-1 border border-border/50 rounded text-sm disabled:opacity-50"
>
Вперёд
</button>
</div>
</div>
)}
</>
)}
</div>
{modalPost !== undefined && (
<PostModal
post={modalPost || undefined}
onClose={() => setModalPost(undefined)}
onSave={handleSave}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,424 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import {
Save,
Globe,
Cpu,
Search,
ToggleLeft,
ToggleRight,
} from 'lucide-react';
import {
fetchPlatformSettings,
updatePlatformSettings,
fetchFeatureFlags,
updateFeatureFlags,
} from '@/lib/api';
import type { PlatformSettings, FeatureFlags } from '@/lib/types';
export default function AdminSettingsPage() {
const [settings, setSettings] = useState<PlatformSettings | null>(null);
const [features, setFeatures] = useState<FeatureFlags | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [activeTab, setActiveTab] = useState<'general' | 'features' | 'llm' | 'search'>('general');
const loadSettings = useCallback(async () => {
setLoading(true);
try {
const [settingsData, featuresData] = await Promise.all([
fetchPlatformSettings(),
fetchFeatureFlags(),
]);
setSettings(settingsData);
setFeatures(featuresData);
} catch (err) {
console.error('Failed to load settings:', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadSettings();
}, [loadSettings]);
const handleSaveGeneral = async () => {
if (!settings) return;
setSaving(true);
try {
const updated = await updatePlatformSettings({
siteName: settings.siteName,
siteUrl: settings.siteUrl,
logoUrl: settings.logoUrl,
faviconUrl: settings.faviconUrl,
description: settings.description,
supportEmail: settings.supportEmail,
});
setSettings(updated);
} catch (err) {
console.error('Failed to save settings:', err);
} finally {
setSaving(false);
}
};
const handleSaveFeatures = async () => {
if (!features) return;
setSaving(true);
try {
await updateFeatureFlags(features);
} catch (err) {
console.error('Failed to save features:', err);
} finally {
setSaving(false);
}
};
const handleSaveLLM = async () => {
if (!settings) return;
setSaving(true);
try {
const updated = await updatePlatformSettings({
llmSettings: settings.llmSettings,
});
setSettings(updated);
} catch (err) {
console.error('Failed to save LLM settings:', err);
} finally {
setSaving(false);
}
};
const handleSaveSearch = async () => {
if (!settings) return;
setSaving(true);
try {
const updated = await updatePlatformSettings({
searchSettings: settings.searchSettings,
});
setSettings(updated);
} catch (err) {
console.error('Failed to save search settings:', err);
} finally {
setSaving(false);
}
};
const toggleFeature = (key: keyof FeatureFlags) => {
if (!features) return;
setFeatures({ ...features, [key]: !features[key] });
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
if (!settings || !features) {
return (
<div className="bg-red-500/10 text-red-400 p-4 rounded-lg">
Не удалось загрузить настройки
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-primary">Настройки</h1>
<p className="text-muted">Конфигурация платформы GooSeek</p>
</div>
<div className="flex gap-2 border-b border-border/30">
{[
{ id: 'general', label: 'Общие', icon: Globe },
{ id: 'features', label: 'Функции', icon: ToggleRight },
{ id: 'llm', label: 'LLM', icon: Cpu },
{ id: 'search', label: 'Поиск', icon: Search },
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as typeof activeTab)}
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-primary text-primary'
: 'border-transparent text-muted hover:text-secondary'
}`}
>
<tab.icon className="w-4 h-4" />
{tab.label}
</button>
))}
</div>
{activeTab === 'general' && (
<div className="bg-surface rounded-xl border border-border/30 p-6 space-y-6">
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-sm text-secondary mb-1">Название сайта</label>
<input
type="text"
value={settings.siteName}
onChange={(e) => setSettings({ ...settings, siteName: e.target.value })}
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
/>
</div>
<div>
<label className="block text-sm text-secondary mb-1">URL сайта</label>
<input
type="url"
value={settings.siteUrl}
onChange={(e) => setSettings({ ...settings, siteUrl: e.target.value })}
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-sm text-secondary mb-1">URL логотипа</label>
<input
type="url"
value={settings.logoUrl || ''}
onChange={(e) => setSettings({ ...settings, logoUrl: e.target.value })}
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
/>
</div>
<div>
<label className="block text-sm text-secondary mb-1">URL favicon</label>
<input
type="url"
value={settings.faviconUrl || ''}
onChange={(e) => setSettings({ ...settings, faviconUrl: e.target.value })}
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
/>
</div>
</div>
<div>
<label className="block text-sm text-secondary mb-1">Описание</label>
<textarea
value={settings.description || ''}
onChange={(e) => setSettings({ ...settings, description: e.target.value })}
rows={3}
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary resize-none"
/>
</div>
<div>
<label className="block text-sm text-secondary mb-1">Email поддержки</label>
<input
type="email"
value={settings.supportEmail || ''}
onChange={(e) => setSettings({ ...settings, supportEmail: e.target.value })}
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
/>
</div>
<div className="flex justify-end pt-4">
<button
onClick={handleSaveGeneral}
disabled={saving}
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50"
>
<Save className="w-4 h-4" />
{saving ? 'Сохранение...' : 'Сохранить'}
</button>
</div>
</div>
)}
{activeTab === 'features' && (
<div className="bg-surface rounded-xl border border-border/30 p-6 space-y-4">
{[
{ key: 'enableRegistration', label: 'Регистрация', description: 'Разрешить новым пользователям регистрироваться' },
{ key: 'enableDiscover', label: 'Discover', description: 'Раздел новостей и дайджестов' },
{ key: 'enableFinance', label: 'Финансы', description: 'Финансовые инструменты и карта рынка' },
{ key: 'enableLearning', label: 'Обучение', description: 'Интерактивные уроки' },
{ key: 'enableTravel', label: 'Путешествия', description: 'Поиск авиабилетов и отелей' },
{ key: 'enableMedicine', label: 'Медицина', description: 'Медицинский ассистент' },
{ key: 'enableFileUploads', label: 'Загрузка файлов', description: 'Возможность прикреплять файлы к чатам' },
{ key: 'maintenanceMode', label: 'Режим обслуживания', description: 'Временно закрыть доступ к сайту' },
].map((feature) => (
<div key={feature.key} className="flex items-center justify-between py-3 border-b border-border/20 last:border-0">
<div>
<p className="text-primary font-medium">{feature.label}</p>
<p className="text-sm text-muted">{feature.description}</p>
</div>
<button
onClick={() => toggleFeature(feature.key as keyof FeatureFlags)}
className={features[feature.key as keyof FeatureFlags] ? 'text-green-400' : 'text-muted'}
>
{features[feature.key as keyof FeatureFlags] ? (
<ToggleRight className="w-8 h-8" />
) : (
<ToggleLeft className="w-8 h-8" />
)}
</button>
</div>
))}
<div className="flex justify-end pt-4">
<button
onClick={handleSaveFeatures}
disabled={saving}
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50"
>
<Save className="w-4 h-4" />
{saving ? 'Сохранение...' : 'Сохранить'}
</button>
</div>
</div>
)}
{activeTab === 'llm' && (
<div className="bg-surface rounded-xl border border-border/30 p-6 space-y-6">
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-sm text-secondary mb-1">Провайдер по умолчанию</label>
<select
value={settings.llmSettings.defaultProvider}
onChange={(e) => setSettings({
...settings,
llmSettings: { ...settings.llmSettings, defaultProvider: e.target.value }
})}
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
>
<option value="timeweb">Timeweb Cloud</option>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="gemini">Google Gemini</option>
</select>
</div>
<div>
<label className="block text-sm text-secondary mb-1">Модель по умолчанию</label>
<input
type="text"
value={settings.llmSettings.defaultModel}
onChange={(e) => setSettings({
...settings,
llmSettings: { ...settings.llmSettings, defaultModel: e.target.value }
})}
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-sm text-secondary mb-1">Максимум токенов</label>
<input
type="number"
value={settings.llmSettings.maxTokens}
onChange={(e) => setSettings({
...settings,
llmSettings: { ...settings.llmSettings, maxTokens: parseInt(e.target.value) || 4096 }
})}
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
/>
</div>
<div>
<label className="block text-sm text-secondary mb-1">
Temperature: {settings.llmSettings.temperature.toFixed(2)}
</label>
<input
type="range"
min="0"
max="2"
step="0.1"
value={settings.llmSettings.temperature}
onChange={(e) => setSettings({
...settings,
llmSettings: { ...settings.llmSettings, temperature: parseFloat(e.target.value) }
})}
className="w-full mt-2"
/>
</div>
</div>
<div className="flex justify-end pt-4">
<button
onClick={handleSaveLLM}
disabled={saving}
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50"
>
<Save className="w-4 h-4" />
{saving ? 'Сохранение...' : 'Сохранить'}
</button>
</div>
</div>
)}
{activeTab === 'search' && (
<div className="bg-surface rounded-xl border border-border/30 p-6 space-y-6">
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-sm text-secondary mb-1">Поисковый движок</label>
<select
value={settings.searchSettings.defaultEngine}
onChange={(e) => setSettings({
...settings,
searchSettings: { ...settings.searchSettings, defaultEngine: e.target.value }
})}
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
>
<option value="searxng">SearXNG</option>
<option value="google">Google</option>
<option value="bing">Bing</option>
<option value="duckduckgo">DuckDuckGo</option>
</select>
</div>
<div>
<label className="block text-sm text-secondary mb-1">Максимум результатов</label>
<input
type="number"
value={settings.searchSettings.maxResults}
onChange={(e) => setSettings({
...settings,
searchSettings: { ...settings.searchSettings, maxResults: parseInt(e.target.value) || 10 }
})}
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
/>
</div>
</div>
<div className="flex items-center justify-between py-3">
<div>
<p className="text-primary font-medium">Безопасный поиск</p>
<p className="text-sm text-muted">Фильтровать взрослый контент</p>
</div>
<button
onClick={() => setSettings({
...settings,
searchSettings: { ...settings.searchSettings, safeSearch: !settings.searchSettings.safeSearch }
})}
className={settings.searchSettings.safeSearch ? 'text-green-400' : 'text-muted'}
>
{settings.searchSettings.safeSearch ? (
<ToggleRight className="w-8 h-8" />
) : (
<ToggleLeft className="w-8 h-8" />
)}
</button>
</div>
<div className="flex justify-end pt-4">
<button
onClick={handleSaveSearch}
disabled={saving}
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50"
>
<Save className="w-4 h-4" />
{saving ? 'Сохранение...' : 'Сохранить'}
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,386 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import {
Search,
Plus,
MoreVertical,
Edit,
Trash2,
UserCheck,
UserX,
Crown,
} from 'lucide-react';
import {
fetchAdminUsers,
createAdminUser,
updateAdminUser,
deleteAdminUser
} from '@/lib/api';
import type { AdminUser, UserRole, UserTier } from '@/lib/types';
interface UserModalProps {
user?: AdminUser;
onClose: () => void;
onSave: (data: { email: string; password?: string; displayName: string; role: UserRole; tier: UserTier }) => void;
}
function UserModal({ user, onClose, onSave }: UserModalProps) {
const [email, setEmail] = useState(user?.email || '');
const [password, setPassword] = useState('');
const [displayName, setDisplayName] = useState(user?.displayName || '');
const [role, setRole] = useState<UserRole>(user?.role || 'user');
const [tier, setTier] = useState<UserTier>(user?.tier || 'free');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave({
email,
password: password || undefined,
displayName,
role,
tier
});
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-surface rounded-xl p-6 w-full max-w-md">
<h2 className="text-xl font-bold text-primary mb-4">
{user ? 'Редактировать пользователя' : 'Новый пользователь'}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm text-secondary mb-1">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
required
/>
</div>
{!user && (
<div>
<label className="block text-sm text-secondary mb-1">Пароль</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
required={!user}
/>
</div>
)}
<div>
<label className="block text-sm text-secondary mb-1">Имя</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-secondary mb-1">Роль</label>
<select
value={role}
onChange={(e) => setRole(e.target.value as UserRole)}
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
>
<option value="user">Пользователь</option>
<option value="admin">Администратор</option>
</select>
</div>
<div>
<label className="block text-sm text-secondary mb-1">Тариф</label>
<select
value={tier}
onChange={(e) => setTier(e.target.value as UserTier)}
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
>
<option value="free">Free</option>
<option value="pro">Pro</option>
<option value="business">Business</option>
</select>
</div>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2 border border-border/50 rounded-lg text-secondary hover:bg-base transition-colors"
>
Отмена
</button>
<button
type="submit"
className="flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
>
Сохранить
</button>
</div>
</form>
</div>
</div>
);
}
export default function AdminUsersPage() {
const [users, setUsers] = useState<AdminUser[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true);
const [modalUser, setModalUser] = useState<AdminUser | null | undefined>(undefined);
const [activeMenu, setActiveMenu] = useState<string | null>(null);
const loadUsers = useCallback(async () => {
setLoading(true);
try {
const data = await fetchAdminUsers(page, 20, search || undefined);
setUsers(data.users);
setTotal(data.total);
} catch (err) {
console.error('Failed to load users:', err);
} finally {
setLoading(false);
}
}, [page, search]);
useEffect(() => {
loadUsers();
}, [loadUsers]);
const handleSave = async (data: { email: string; password?: string; displayName: string; role: UserRole; tier: UserTier }) => {
try {
if (modalUser) {
await updateAdminUser(modalUser.id, data);
} else {
await createAdminUser({ ...data, password: data.password || '' });
}
setModalUser(undefined);
loadUsers();
} catch (err) {
console.error('Failed to save user:', err);
}
};
const handleDelete = async (id: string) => {
if (!confirm('Удалить пользователя?')) return;
try {
await deleteAdminUser(id);
loadUsers();
} catch (err) {
console.error('Failed to delete user:', err);
}
};
const handleToggleActive = async (user: AdminUser) => {
try {
await updateAdminUser(user.id, { isActive: !user.isActive });
loadUsers();
} catch (err) {
console.error('Failed to toggle user:', err);
}
};
const totalPages = Math.ceil(total / 20);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-primary">Пользователи</h1>
<p className="text-muted">Управление пользователями платформы</p>
</div>
<button
onClick={() => setModalUser(null)}
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
>
<Plus className="w-4 h-4" />
Добавить
</button>
</div>
<div className="bg-surface rounded-xl border border-border/30">
<div className="p-4 border-b border-border/30">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted" />
<input
type="text"
placeholder="Поиск по email или имени..."
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
className="w-full pl-10 pr-4 py-2 bg-base border border-border/50 rounded-lg text-primary placeholder:text-muted focus:outline-none focus:border-primary"
/>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border/30">
<th className="text-left px-4 py-3 text-sm font-medium text-muted">Пользователь</th>
<th className="text-left px-4 py-3 text-sm font-medium text-muted">Роль</th>
<th className="text-left px-4 py-3 text-sm font-medium text-muted">Тариф</th>
<th className="text-left px-4 py-3 text-sm font-medium text-muted">Статус</th>
<th className="text-left px-4 py-3 text-sm font-medium text-muted">Создан</th>
<th className="w-12"></th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className="border-b border-border/20 hover:bg-base/50">
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
{user.avatarUrl ? (
<img src={user.avatarUrl} alt="" className="w-10 h-10 rounded-full" />
) : (
<span className="text-primary font-medium">
{user.displayName.charAt(0).toUpperCase()}
</span>
)}
</div>
<div>
<p className="text-primary font-medium">{user.displayName}</p>
<p className="text-xs text-muted">{user.email}</p>
</div>
</div>
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs ${
user.role === 'admin'
? 'bg-purple-500/10 text-purple-400'
: 'bg-blue-500/10 text-blue-400'
}`}>
{user.role === 'admin' && <Crown className="w-3 h-3" />}
{user.role === 'admin' ? 'Админ' : 'Пользователь'}
</span>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded-full text-xs ${
user.tier === 'business'
? 'bg-yellow-500/10 text-yellow-400'
: user.tier === 'pro'
? 'bg-green-500/10 text-green-400'
: 'bg-gray-500/10 text-gray-400'
}`}>
{user.tier.toUpperCase()}
</span>
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs ${
user.isActive
? 'bg-green-500/10 text-green-400'
: 'bg-red-500/10 text-red-400'
}`}>
{user.isActive ? <UserCheck className="w-3 h-3" /> : <UserX className="w-3 h-3" />}
{user.isActive ? 'Активен' : 'Неактивен'}
</span>
</td>
<td className="px-4 py-3 text-sm text-muted">
{new Date(user.createdAt).toLocaleDateString('ru-RU')}
</td>
<td className="px-4 py-3">
<div className="relative">
<button
onClick={() => setActiveMenu(activeMenu === user.id ? null : user.id)}
className="p-1 hover:bg-base rounded"
>
<MoreVertical className="w-4 h-4 text-muted" />
</button>
{activeMenu === user.id && (
<div className="absolute right-0 top-full mt-1 bg-surface border border-border/50 rounded-lg shadow-lg z-10 min-w-[150px]">
<button
onClick={() => {
setModalUser(user);
setActiveMenu(null);
}}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-secondary hover:bg-base"
>
<Edit className="w-4 h-4" />
Редактировать
</button>
<button
onClick={() => {
handleToggleActive(user);
setActiveMenu(null);
}}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-secondary hover:bg-base"
>
{user.isActive ? <UserX className="w-4 h-4" /> : <UserCheck className="w-4 h-4" />}
{user.isActive ? 'Деактивировать' : 'Активировать'}
</button>
<button
onClick={() => {
handleDelete(user.id);
setActiveMenu(null);
}}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-400 hover:bg-base"
>
<Trash2 className="w-4 h-4" />
Удалить
</button>
</div>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-border/30">
<p className="text-sm text-muted">
Показано {users.length} из {total}
</p>
<div className="flex gap-2">
<button
onClick={() => setPage(Math.max(1, page - 1))}
disabled={page === 1}
className="px-3 py-1 border border-border/50 rounded text-sm disabled:opacity-50"
>
Назад
</button>
<span className="px-3 py-1 text-sm text-muted">
{page} / {totalPages}
</span>
<button
onClick={() => setPage(Math.min(totalPages, page + 1))}
disabled={page === totalPages}
className="px-3 py-1 border border-border/50 rounded text-sm disabled:opacity-50"
>
Вперёд
</button>
</div>
</div>
)}
</>
)}
</div>
{modalUser !== undefined && (
<UserModal
user={modalUser || undefined}
onClose={() => setModalUser(undefined)}
onSave={handleSave}
/>
)}
</div>
);
}

View File

@@ -1,752 +0,0 @@
'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-cyan-400', bg: 'bg-cyan-400/10', icon: Calendar, label: 'Запланировано' },
paused: { color: 'text-warning', bg: 'bg-warning/10', icon: Pause, label: 'Пауза' },
checkpoint: { color: 'text-gradient', bg: 'active-gradient', 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-gradient" />
Задачи
</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-gradient" />
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 progress-gradient rounded-full"
/>
</div>
<div className="flex justify-between mt-1.5">
<span className="text-xs text-muted">Прогресс</span>
<span className="text-xs text-gradient">{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-gradient transition-colors"
>
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg icon-gradient flex-shrink-0">
<Icon className="w-5 h-5 text-gradient" />
</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 input-gradient 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 text-center transition-all ${
durationMode === mode.value
? 'active-gradient text-primary'
: 'bg-elevated/40 border 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-gradient' : 'text-muted'
}`} />
<div className={`text-xs sm:text-sm font-medium ${durationMode === mode.value ? 'text-gradient' : ''}`}>{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-gradient transition-colors text-left"
>
<conn.icon className="w-4 h-4 text-gradient 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-gradient 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-gradient 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-gradient opacity-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 loader-gradient 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 transition-all ${
selectedTask?.id === task.id
? 'bg-elevated/60 active-gradient'
: 'bg-elevated/40 border 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 progress-gradient"
/>
</div>
)}
</div>
</div>
</motion.button>
);
})
)}
</div>
);
}

View File

@@ -1,9 +1,13 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { motion } from 'framer-motion';
import { RefreshCw, ExternalLink, Loader2, Sparkles, Globe, Cpu, DollarSign, Dumbbell } from 'lucide-react';
import {
RefreshCw, ExternalLink, Loader2, Sparkles, Globe, Cpu, DollarSign,
Dumbbell, Landmark, FlaskConical, Heart, Clapperboard, Briefcase,
Palette, ChevronLeft, ChevronRight
} from 'lucide-react';
import * as Tabs from '@radix-ui/react-tabs';
import { fetchDiscover } from '@/lib/api';
import type { DiscoverItem } from '@/lib/types';
@@ -12,37 +16,122 @@ const topics = [
{ id: 'tech', label: 'Технологии', icon: Cpu },
{ id: 'finance', label: 'Финансы', icon: DollarSign },
{ id: 'sports', label: 'Спорт', icon: Dumbbell },
{ id: 'politics', label: 'Политика', icon: Landmark },
{ id: 'science', label: 'Наука', icon: FlaskConical },
{ id: 'health', label: 'Здоровье', icon: Heart },
{ id: 'entertainment', label: 'Развлечения', icon: Clapperboard },
{ id: 'world', label: 'В мире', icon: Globe },
{ id: 'business', label: 'Бизнес', icon: Briefcase },
{ id: 'culture', label: 'Культура', icon: Palette },
] as const;
const ITEMS_PER_PAGE = 10;
export default function DiscoverPage() {
const router = useRouter();
const [items, setItems] = useState<DiscoverItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [topic, setTopic] = useState<string>('tech');
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const tabsRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const loadMoreRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const checkScroll = useCallback(() => {
const el = tabsRef.current;
if (!el) return;
setCanScrollLeft(el.scrollLeft > 0);
setCanScrollRight(el.scrollLeft < el.scrollWidth - el.clientWidth - 1);
}, []);
const scrollTabs = useCallback((direction: 'left' | 'right') => {
const el = tabsRef.current;
if (!el) return;
const scrollAmount = 200;
el.scrollBy({
left: direction === 'left' ? -scrollAmount : scrollAmount,
behavior: 'smooth'
});
}, []);
useEffect(() => {
checkScroll();
window.addEventListener('resize', checkScroll);
return () => window.removeEventListener('resize', checkScroll);
}, [checkScroll]);
const handleSummarize = useCallback((url: string) => {
const query = encodeURIComponent(`Summary: ${url}`);
router.push(`/?q=${query}`);
}, [router]);
const load = useCallback(async () => {
const loadInitial = useCallback(async () => {
setIsLoading(true);
setPage(1);
setHasMore(true);
try {
const data = await fetchDiscover(topic, 'russia');
setItems(data);
const response = await fetchDiscover(topic, 'russia', 1, ITEMS_PER_PAGE);
setItems(response.blogs);
setHasMore(response.hasMore);
setPage(1);
} catch {
setItems([]);
setHasMore(false);
} finally {
setIsLoading(false);
}
}, [topic]);
const loadMore = useCallback(async () => {
if (isLoadingMore || !hasMore) return;
setIsLoadingMore(true);
try {
const nextPage = page + 1;
const response = await fetchDiscover(topic, 'russia', nextPage, ITEMS_PER_PAGE);
if (response.blogs.length > 0) {
setItems(prev => [...prev, ...response.blogs]);
setPage(nextPage);
}
setHasMore(response.hasMore);
} catch {
setHasMore(false);
} finally {
setIsLoadingMore(false);
}
}, [topic, page, hasMore, isLoadingMore]);
useEffect(() => {
load();
}, [load]);
loadInitial();
}, [loadInitial]);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !isLoading && !isLoadingMore) {
loadMore();
}
},
{ threshold: 0.1, rootMargin: '100px' }
);
const el = loadMoreRef.current;
if (el) observer.observe(el);
return () => {
if (el) observer.unobserve(el);
};
}, [hasMore, isLoading, isLoadingMore, loadMore]);
return (
<div className="h-full overflow-y-auto">
<div ref={containerRef} 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">
@@ -51,7 +140,7 @@ export default function DiscoverPage() {
<p className="text-sm text-secondary">Актуальные новости и события</p>
</div>
<button
onClick={load}
onClick={loadInitial}
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-gradient rounded-xl transition-all w-fit"
>
@@ -60,20 +149,52 @@ export default function DiscoverPage() {
</button>
</div>
{/* Topic Tabs */}
{/* Topic Tabs Carousel */}
<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]:active-gradient data-[state=active]:text-primary bg-surface/30 border-border/30 text-muted hover:text-secondary hover:border-border"
<div className="relative mb-6 sm:mb-8">
{/* Left scroll button */}
{canScrollLeft && (
<button
onClick={() => scrollTabs('left')}
className="absolute left-0 top-1/2 -translate-y-1/2 z-10 w-8 h-8 flex items-center justify-center bg-background/90 backdrop-blur-sm border border-border/50 rounded-full shadow-lg hover:bg-surface transition-colors"
aria-label="Прокрутить влево"
>
<t.icon className="w-4 h-4" />
{t.label}
</Tabs.Trigger>
))}
</Tabs.List>
<ChevronLeft className="w-4 h-4 text-secondary" />
</button>
)}
{/* Tabs container */}
<div
ref={tabsRef}
onScroll={checkScroll}
className="overflow-x-auto scrollbar-hide scroll-smooth"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
<Tabs.List className="flex gap-2 px-1 py-1 w-max">
{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]:active-gradient 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>
</div>
{/* Right scroll button */}
{canScrollRight && (
<button
onClick={() => scrollTabs('right')}
className="absolute right-0 top-1/2 -translate-y-1/2 z-10 w-8 h-8 flex items-center justify-center bg-background/90 backdrop-blur-sm border border-border/50 rounded-full shadow-lg hover:bg-surface transition-colors"
aria-label="Прокрутить вправо"
>
<ChevronRight className="w-4 h-4 text-secondary" />
</button>
)}
</div>
</Tabs.Root>
{/* Content */}
@@ -94,10 +215,10 @@ export default function DiscoverPage() {
<div className="space-y-3 sm:space-y-4">
{items.map((item, i) => (
<motion.article
key={i}
key={`${item.url}-${i}`}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.05 }}
transition={{ delay: Math.min(i * 0.05, 0.5) }}
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-gradient hover:bg-elevated/60 transition-all duration-200"
>
{/* Thumbnail */}
@@ -155,6 +276,21 @@ export default function DiscoverPage() {
</div>
</motion.article>
))}
{/* Infinite scroll trigger & loading indicator */}
<div ref={loadMoreRef} className="py-4">
{isLoadingMore && (
<div className="flex items-center justify-center gap-2 py-4">
<Loader2 className="w-5 h-5 animate-spin text-muted" />
<span className="text-sm text-muted">Загрузка...</span>
</div>
)}
{!hasMore && items.length > 0 && (
<p className="text-center text-sm text-muted py-4">
Все новости загружены
</p>
)}
</div>
</div>
)}
</div>

View File

@@ -40,7 +40,7 @@ export default function MainLayout({ children }: { children: React.ReactNode })
>
<Menu className="w-5 h-5" />
</button>
<span className="text-sm font-semibold text-primary">GooSeek</span>
<span className="font-black italic text-primary tracking-tight text-2xl">GooSeek</span>
<div className="w-10" />
</div>
)}

View File

@@ -0,0 +1,298 @@
'use client';
import { useState, useCallback } from 'react';
import { motion } from 'framer-motion';
import {
HeartPulse,
Search,
Pill,
Stethoscope,
FileText,
AlertTriangle,
Clock,
Loader2,
Sparkles,
Activity,
Thermometer,
Brain,
Eye,
Bone,
Wind,
Droplet,
Shield,
} from 'lucide-react';
interface Symptom {
id: string;
name: string;
icon: React.ElementType;
category: string;
}
interface Article {
id: string;
title: string;
category: string;
readTime: string;
icon: string;
}
const commonSymptoms: Symptom[] = [
{ id: '1', name: 'Головная боль', icon: Brain, category: 'Неврология' },
{ id: '2', name: 'Температура', icon: Thermometer, category: 'Общие' },
{ id: '3', name: 'Боль в горле', icon: Wind, category: 'ЛОР' },
{ id: '4', name: 'Боль в спине', icon: Bone, category: 'Ортопедия' },
{ id: '5', name: 'Проблемы со зрением', icon: Eye, category: 'Офтальмология' },
{ id: '6', name: 'Давление', icon: Activity, category: 'Кардиология' },
{ id: '7', name: 'Аллергия', icon: Droplet, category: 'Аллергология' },
{ id: '8', name: 'Усталость', icon: HeartPulse, category: 'Общие' },
];
const healthArticles: Article[] = [
{
id: '1',
title: 'Как укрепить иммунитет зимой',
category: 'Профилактика',
readTime: '5 мин',
icon: '🛡️',
},
{
id: '2',
title: 'Правильное питание для сердца',
category: 'Кардиология',
readTime: '7 мин',
icon: '❤️',
},
{
id: '3',
title: 'Упражнения для здоровой спины',
category: 'Ортопедия',
readTime: '6 мин',
icon: '🏃',
},
{
id: '4',
title: 'Как справиться со стрессом',
category: 'Психология',
readTime: '8 мин',
icon: '🧘',
},
];
const quickServices = [
{ icon: Stethoscope, label: 'Найти врача', color: 'bg-blue-500/10 text-blue-400' },
{ icon: Pill, label: 'Справочник лекарств', color: 'bg-green-500/10 text-green-400' },
{ icon: FileText, label: 'Анализы', color: 'bg-purple-500/10 text-purple-400' },
{ icon: Sparkles, label: 'AI Консультант', color: 'active-gradient text-gradient' },
];
function SymptomButton({ symptom, onClick }: { symptom: Symptom; onClick: () => void }) {
return (
<button
onClick={onClick}
className="flex items-center gap-2 px-3 py-2 bg-surface/50 border border-border/40 rounded-lg hover:border-border hover:bg-surface/70 transition-all text-left"
>
<symptom.icon className="w-4 h-4 text-muted flex-shrink-0" />
<span className="text-sm text-secondary truncate">{symptom.name}</span>
</button>
);
}
function ArticleCard({ article, delay }: { article: Article; delay: number }) {
return (
<motion.div
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay }}
className="flex items-center gap-3 p-3 bg-elevated/40 border border-border/40 rounded-xl hover:border-border transition-all cursor-pointer group"
>
<div className="w-10 h-10 rounded-xl bg-surface/60 flex items-center justify-center text-xl flex-shrink-0">
{article.icon}
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-primary group-hover:text-gradient transition-colors truncate">
{article.title}
</h3>
<div className="flex items-center gap-2 text-xs text-muted">
<span>{article.category}</span>
<span></span>
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{article.readTime}
</span>
</div>
</div>
</motion.div>
);
}
export default function MedicinePage() {
const [searchQuery, setSearchQuery] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [aiResponse, setAiResponse] = useState<string | null>(null);
const handleAIConsult = useCallback(async () => {
if (!searchQuery.trim()) return;
setIsLoading(true);
setAiResponse(null);
await new Promise((resolve) => setTimeout(resolve, 2000));
setAiResponse(
'На основе описанных симптомов рекомендую обратиться к терапевту для первичного осмотра. ' +
'Это может быть связано с несколькими причинами, которые требуют диагностики. ' +
'До визита к врачу: пейте больше жидкости, отдыхайте, избегайте переохлаждения.'
);
setIsLoading(false);
}, [searchQuery]);
const handleSymptomClick = (symptomName: string) => {
setSearchQuery(symptomName);
};
return (
<div className="h-full overflow-y-auto">
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-6 sm:py-8">
{/* Header */}
<div className="mb-6 sm:mb-8">
<h1 className="text-xl sm:text-2xl font-semibold text-primary mb-1 sm:mb-2">Медицина</h1>
<p className="text-sm text-secondary">AI-помощник по здоровью и медицине</p>
</div>
{/* Disclaimer */}
<div className="flex items-start gap-3 p-4 bg-amber-500/5 border border-amber-500/20 rounded-xl mb-6">
<AlertTriangle className="w-5 h-5 text-amber-400 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-amber-400 mb-1">Важно</p>
<p className="text-xs text-muted">
Информация носит справочный характер и не заменяет консультацию врача.
При серьёзных симптомах обратитесь к специалисту.
</p>
</div>
</div>
{/* AI Search */}
<div className="bg-elevated/40 border border-border/40 rounded-xl p-4 mb-6">
<div className="relative mb-4">
<Sparkles className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gradient" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAIConsult()}
placeholder="Опишите симптомы или задайте вопрос о здоровье..."
className="w-full h-12 pl-12 pr-4 bg-surface/50 border border-border/50 rounded-xl text-sm text-primary placeholder:text-muted focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20 transition-all"
/>
</div>
<button
onClick={handleAIConsult}
disabled={isLoading || !searchQuery.trim()}
className="w-full h-11 flex items-center justify-center gap-2 active-gradient text-gradient font-medium text-sm rounded-xl disabled:opacity-50 disabled:cursor-not-allowed transition-opacity"
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
AI анализирует...
</>
) : (
<>
<Search className="w-4 h-4" />
Получить консультацию AI
</>
)}
</button>
{/* AI Response */}
{aiResponse && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="mt-4 p-4 bg-surface/50 border border-border/40 rounded-xl"
>
<div className="flex items-center gap-2 mb-2">
<Shield className="w-4 h-4 text-gradient" />
<span className="text-xs font-medium text-gradient">Рекомендация AI</span>
</div>
<p className="text-sm text-secondary leading-relaxed">{aiResponse}</p>
</motion.div>
)}
</div>
{/* Quick Services */}
<div className="grid grid-cols-4 gap-2 sm:gap-3 mb-6 sm:mb-8">
{quickServices.map((service) => (
<button
key={service.label}
className={`flex flex-col items-center gap-2 p-3 sm:p-4 rounded-xl border border-border/40 hover:border-border transition-all ${service.color}`}
>
<service.icon className="w-5 h-5 sm:w-6 sm:h-6" />
<span className="text-[10px] sm:text-xs font-medium text-center leading-tight">
{service.label}
</span>
</button>
))}
</div>
{/* Common Symptoms */}
<div className="mb-6 sm:mb-8">
<div className="flex items-center gap-3 mb-4">
<div className="w-9 h-9 rounded-xl bg-red-500/10 flex items-center justify-center">
<HeartPulse className="w-4 h-4 text-red-400" />
</div>
<h2 className="text-sm font-medium text-primary">Частые симптомы</h2>
</div>
<div className="grid grid-cols-2 gap-2">
{commonSymptoms.map((symptom) => (
<SymptomButton
key={symptom.id}
symptom={symptom}
onClick={() => handleSymptomClick(symptom.name)}
/>
))}
</div>
</div>
{/* Health Articles */}
<div className="mb-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-9 h-9 rounded-xl bg-green-500/10 flex items-center justify-center">
<FileText className="w-4 h-4 text-green-400" />
</div>
<h2 className="text-sm font-medium text-primary">Полезные статьи</h2>
</div>
<div className="space-y-2">
{healthArticles.map((article, i) => (
<ArticleCard key={article.id} article={article} delay={i * 0.05} />
))}
</div>
</div>
{/* Emergency Info */}
<div className="bg-red-500/5 border border-red-500/20 rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<AlertTriangle className="w-4 h-4 text-red-400" />
<span className="text-sm font-medium text-red-400">Экстренная помощь</span>
</div>
<p className="text-sm text-muted mb-3">
При угрозе жизни немедленно вызывайте скорую помощь
</p>
<div className="flex gap-3">
<a
href="tel:103"
className="flex-1 h-10 flex items-center justify-center gap-2 bg-red-500/10 border border-red-500/30 text-red-400 font-medium text-sm rounded-lg hover:bg-red-500/20 transition-colors"
>
📞 103
</a>
<a
href="tel:112"
className="flex-1 h-10 flex items-center justify-center gap-2 bg-red-500/10 border border-red-500/30 text-red-400 font-medium text-sm rounded-lg hover:bg-red-500/20 transition-colors"
>
📞 112
</a>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,20 +1,24 @@
'use client';
import { useRef, useEffect, useState, Suspense } from 'react';
import { useRef, useEffect, useState, Suspense, useCallback } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { motion, AnimatePresence } from 'framer-motion';
import { ChatInput } from '@/components/ChatInput';
import { ChatInput, SendOptions } from '@/components/ChatInput';
import { ChatMessage } from '@/components/ChatMessage';
import { useChat } from '@/lib/hooks/useChat';
function HomePageContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { messages, isLoading, sendMessage, stopGeneration } = useChat();
const { messages, isLoading, sendMessage: sendChatMessage, stopGeneration } = useChat();
const messagesEndRef = useRef<HTMLDivElement>(null);
const [showWelcome, setShowWelcome] = useState(true);
const initialQueryProcessed = useRef(false);
const handleSend = useCallback((message: string, options: SendOptions) => {
sendChatMessage(message, options);
}, [sendChatMessage]);
useEffect(() => {
if (messages.length > 0) {
setShowWelcome(false);
@@ -26,9 +30,9 @@ function HomePageContent() {
if (query && !initialQueryProcessed.current && !isLoading) {
initialQueryProcessed.current = true;
router.replace('/', { scroll: false });
sendMessage(decodeURIComponent(query));
sendChatMessage(decodeURIComponent(query));
}
}, [searchParams, isLoading, sendMessage, router]);
}, [searchParams, isLoading, sendChatMessage, router]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
@@ -68,7 +72,7 @@ function HomePageContent() {
className="w-full max-w-2xl px-2"
>
<ChatInput
onSend={sendMessage}
onSend={handleSend}
onStop={stopGeneration}
isLoading={isLoading}
placeholder="Спросите что угодно..."
@@ -106,7 +110,7 @@ function HomePageContent() {
<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}
onSend={handleSend}
onStop={stopGeneration}
isLoading={isLoading}
placeholder="Продолжить диалог..."

View File

@@ -2,15 +2,18 @@
import { useState } from 'react';
import { motion } from 'framer-motion';
import { Shield, Zap, Scale, Sparkles, Download, Trash2, Bell, Eye, Languages } from 'lucide-react';
import { Shield, Zap, Scale, Sparkles, Download, Trash2, Bell, Eye, Languages, Plug, ChevronDown } from 'lucide-react';
import * as Switch from '@radix-ui/react-switch';
import { useLanguage, SupportedLanguage } from '@/lib/contexts/LanguageContext';
import { ConnectorsSettings } from '@/components/settings/ConnectorsSettings';
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');
const [showConnectors, setShowConnectors] = useState(false);
const { language, setLanguage } = useLanguage();
return (
<div className="h-full overflow-y-auto">
@@ -83,7 +86,7 @@ export default function SettingsPage() {
].map((lang) => (
<button
key={lang.id}
onClick={() => setLanguage(lang.id)}
onClick={() => setLanguage(lang.id as SupportedLanguage)}
className={`flex items-center gap-3 p-3 sm:p-4 rounded-xl border transition-all ${
language === lang.id
? 'active-gradient text-primary'
@@ -97,6 +100,40 @@ export default function SettingsPage() {
</div>
</Section>
{/* Connectors */}
<Section title="Коннекторы" icon={Plug}>
<div className="space-y-3">
<p className="text-xs text-muted">
Подключите источники данных и каналы уведомлений для автономных задач в Лаптопе.
</p>
<button
onClick={() => setShowConnectors(!showConnectors)}
className="w-full flex items-center justify-between p-3 sm:p-4 bg-elevated/40 border border-border/40 rounded-xl hover:bg-elevated/60 hover:border-border transition-all"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-indigo-500/10 flex items-center justify-center">
<Plug className="w-5 h-5 text-indigo-400" />
</div>
<div className="text-left">
<p className="text-sm font-medium text-primary">Настроить коннекторы</p>
<p className="text-xs text-muted">Поиск, данные, финансы, уведомления</p>
</div>
</div>
<ChevronDown className={`w-5 h-5 text-muted transition-transform ${showConnectors ? 'rotate-180' : ''}`} />
</button>
{showConnectors && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
className="pt-2"
>
<ConnectorsSettings />
</motion.div>
)}
</div>
</Section>
{/* Data Management */}
<Section title="Управление данными" icon={Download}>
<div className="flex flex-col sm:flex-row gap-3">

View File

@@ -0,0 +1,316 @@
'use client';
import { useState, useCallback } from 'react';
import { motion } from 'framer-motion';
import {
Plane,
MapPin,
Calendar,
Users,
Search,
Star,
Clock,
Loader2,
Sparkles,
Globe,
Hotel,
Car,
Palmtree,
Mountain,
Building2,
Waves,
} from 'lucide-react';
interface Destination {
id: string;
name: string;
country: string;
image: string;
rating: number;
price: string;
category: 'beach' | 'city' | 'nature' | 'mountain';
description: string;
}
const popularDestinations: Destination[] = [
{
id: '1',
name: 'Сочи',
country: 'Россия',
image: '🏖️',
rating: 4.7,
price: 'от 15 000 ₽',
category: 'beach',
description: 'Черноморский курорт с горами и морем',
},
{
id: '2',
name: 'Санкт-Петербург',
country: 'Россия',
image: '🏛️',
rating: 4.9,
price: 'от 8 000 ₽',
category: 'city',
description: 'Культурная столица России',
},
{
id: '3',
name: 'Казань',
country: 'Россия',
image: '🕌',
rating: 4.6,
price: 'от 7 500 ₽',
category: 'city',
description: 'Древний город на Волге',
},
{
id: '4',
name: 'Байкал',
country: 'Россия',
image: '🏔️',
rating: 4.8,
price: 'от 25 000 ₽',
category: 'nature',
description: 'Самое глубокое озеро в мире',
},
{
id: '5',
name: 'Калининград',
country: 'Россия',
image: '⚓',
rating: 4.5,
price: 'от 12 000 ₽',
category: 'city',
description: 'Европейский колорит на Балтике',
},
{
id: '6',
name: 'Алтай',
country: 'Россия',
image: '🗻',
rating: 4.7,
price: 'от 20 000 ₽',
category: 'mountain',
description: 'Горные пейзажи и чистый воздух',
},
];
const categories = [
{ id: 'all', label: 'Все', icon: Globe },
{ id: 'beach', label: 'Пляж', icon: Waves },
{ id: 'city', label: 'Города', icon: Building2 },
{ id: 'nature', label: 'Природа', icon: Palmtree },
{ id: 'mountain', label: 'Горы', icon: Mountain },
];
const quickActions = [
{ icon: Plane, label: 'Авиабилеты', color: 'bg-blue-500/10 text-blue-400' },
{ icon: Hotel, label: 'Отели', color: 'bg-purple-500/10 text-purple-400' },
{ icon: Car, label: 'Авто', color: 'bg-green-500/10 text-green-400' },
{ icon: Sparkles, label: 'AI Планировщик', color: 'active-gradient text-gradient' },
];
function DestinationCard({ destination, delay }: { destination: Destination; delay: number }) {
return (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay }}
className="group bg-elevated/40 border border-border/40 rounded-xl overflow-hidden hover:border-border transition-all cursor-pointer"
>
<div className="h-28 sm:h-32 bg-surface/60 flex items-center justify-center text-4xl sm:text-5xl">
{destination.image}
</div>
<div className="p-3 sm:p-4">
<div className="flex items-start justify-between mb-2">
<div>
<h3 className="text-sm font-medium text-primary group-hover:text-gradient transition-colors">
{destination.name}
</h3>
<p className="text-xs text-muted flex items-center gap-1">
<MapPin className="w-3 h-3" />
{destination.country}
</p>
</div>
<div className="flex items-center gap-1 text-xs">
<Star className="w-3 h-3 text-amber-400 fill-amber-400" />
<span className="text-secondary">{destination.rating}</span>
</div>
</div>
<p className="text-xs text-muted mb-3 line-clamp-2">{destination.description}</p>
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-gradient">{destination.price}</span>
<button className="text-xs text-muted hover:text-primary transition-colors">
Подробнее
</button>
</div>
</div>
</motion.div>
);
}
export default function TravelPage() {
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState('all');
const [isLoading, setIsLoading] = useState(false);
const [travelers, setTravelers] = useState(2);
const [departureDate, setDepartureDate] = useState('');
const handleAISearch = useCallback(async () => {
if (!searchQuery.trim()) return;
setIsLoading(true);
await new Promise((resolve) => setTimeout(resolve, 1500));
setIsLoading(false);
}, [searchQuery]);
const filteredDestinations = popularDestinations.filter((d) => {
if (selectedCategory !== 'all' && d.category !== selectedCategory) return false;
if (searchQuery) {
const query = searchQuery.toLowerCase();
return (
d.name.toLowerCase().includes(query) ||
d.country.toLowerCase().includes(query) ||
d.description.toLowerCase().includes(query)
);
}
return true;
});
return (
<div className="h-full overflow-y-auto">
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-6 sm:py-8">
{/* Header */}
<div className="mb-6 sm:mb-8">
<h1 className="text-xl sm:text-2xl font-semibold text-primary mb-1 sm:mb-2">Путешествия</h1>
<p className="text-sm text-secondary">Планируйте поездки с помощью AI</p>
</div>
{/* AI Search */}
<div className="bg-elevated/40 border border-border/40 rounded-xl p-4 mb-6">
<div className="relative mb-4">
<Sparkles className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gradient" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAISearch()}
placeholder="Куда хотите поехать? Опишите своё идеальное путешествие..."
className="w-full h-12 pl-12 pr-4 bg-surface/50 border border-border/50 rounded-xl text-sm text-primary placeholder:text-muted focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20 transition-all"
/>
</div>
<div className="flex flex-wrap gap-3 mb-4">
<div className="flex items-center gap-2 px-3 py-2 bg-surface/50 border border-border/40 rounded-lg">
<Calendar className="w-4 h-4 text-muted" />
<input
type="date"
value={departureDate}
onChange={(e) => setDepartureDate(e.target.value)}
className="bg-transparent text-sm text-secondary outline-none"
/>
</div>
<div className="flex items-center gap-2 px-3 py-2 bg-surface/50 border border-border/40 rounded-lg">
<Users className="w-4 h-4 text-muted" />
<select
value={travelers}
onChange={(e) => setTravelers(Number(e.target.value))}
className="bg-transparent text-sm text-secondary outline-none"
>
<option value={1}>1 человек</option>
<option value={2}>2 человека</option>
<option value={3}>3 человека</option>
<option value={4}>4 человека</option>
<option value={5}>5+ человек</option>
</select>
</div>
</div>
<button
onClick={handleAISearch}
disabled={isLoading || !searchQuery.trim()}
className="w-full h-11 flex items-center justify-center gap-2 active-gradient text-gradient font-medium text-sm rounded-xl disabled:opacity-50 disabled:cursor-not-allowed transition-opacity"
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
AI планирует маршрут...
</>
) : (
<>
<Search className="w-4 h-4" />
Найти с помощью AI
</>
)}
</button>
</div>
{/* Quick Actions */}
<div className="grid grid-cols-4 gap-2 sm:gap-3 mb-6 sm:mb-8">
{quickActions.map((action) => (
<button
key={action.label}
className={`flex flex-col items-center gap-2 p-3 sm:p-4 rounded-xl border border-border/40 hover:border-border transition-all ${action.color}`}
>
<action.icon className="w-5 h-5 sm:w-6 sm:h-6" />
<span className="text-[10px] sm:text-xs font-medium">{action.label}</span>
</button>
))}
</div>
{/* Categories */}
<div className="flex gap-2 mb-6 overflow-x-auto pb-1 -mx-4 px-4 sm:mx-0 sm:px-0">
{categories.map((cat) => (
<button
key={cat.id}
onClick={() => setSelectedCategory(cat.id)}
className={`flex items-center gap-2 px-4 py-2.5 text-sm rounded-xl border transition-all whitespace-nowrap ${
selectedCategory === cat.id
? 'active-gradient text-primary border-transparent'
: 'bg-surface/30 border-border/30 text-muted hover:text-secondary hover:border-border'
}`}
>
<cat.icon className="w-4 h-4" />
{cat.label}
</button>
))}
</div>
{/* Popular Destinations */}
<div className="mb-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-9 h-9 rounded-xl active-gradient flex items-center justify-center">
<Plane className="w-4 h-4" />
</div>
<h2 className="text-sm font-medium text-primary">Популярные направления</h2>
</div>
<div className="grid grid-cols-2 gap-3 sm:gap-4">
{filteredDestinations.map((dest, i) => (
<DestinationCard key={dest.id} destination={dest} delay={i * 0.05} />
))}
</div>
{filteredDestinations.length === 0 && (
<div className="text-center py-12">
<MapPin className="w-12 h-12 mx-auto mb-4 text-muted" />
<p className="text-secondary">Направления не найдены</p>
<p className="text-sm text-muted mt-1">Попробуйте изменить фильтры</p>
</div>
)}
</div>
{/* AI Tips */}
<div className="bg-surface/30 border border-border/30 rounded-xl p-4">
<div className="flex items-center gap-2 mb-3">
<Clock className="w-4 h-4 text-muted" />
<span className="text-xs font-medium text-secondary">Совет AI</span>
</div>
<p className="text-sm text-muted">
Опишите ваши предпочтения в поиске: бюджет, тип отдыха, даты и AI составит
оптимальный маршрут с учётом всех пожеланий.
</p>
</div>
</div>
</div>
);
}