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:
225
backend/webui/src/app/(main)/admin/audit/page.tsx
Normal file
225
backend/webui/src/app/(main)/admin/audit/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
513
backend/webui/src/app/(main)/admin/discover/page.tsx
Normal file
513
backend/webui/src/app/(main)/admin/discover/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
125
backend/webui/src/app/(main)/admin/layout.tsx
Normal file
125
backend/webui/src/app/(main)/admin/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
180
backend/webui/src/app/(main)/admin/page.tsx
Normal file
180
backend/webui/src/app/(main)/admin/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
415
backend/webui/src/app/(main)/admin/posts/page.tsx
Normal file
415
backend/webui/src/app/(main)/admin/posts/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
424
backend/webui/src/app/(main)/admin/settings/page.tsx
Normal file
424
backend/webui/src/app/(main)/admin/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
386
backend/webui/src/app/(main)/admin/users/page.tsx
Normal file
386
backend/webui/src/app/(main)/admin/users/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
298
backend/webui/src/app/(main)/medicine/page.tsx
Normal file
298
backend/webui/src/app/(main)/medicine/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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="Продолжить диалог..."
|
||||
|
||||
@@ -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">
|
||||
|
||||
316
backend/webui/src/app/(main)/travel/page.tsx
Normal file
316
backend/webui/src/app/(main)/travel/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user